12 Commits

Author SHA1 Message Date
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
130 changed files with 37883 additions and 743 deletions

View File

@@ -0,0 +1,175 @@
---
trigger: manual
---
# 역할: Anti-Gravity Builder @psix-frontend
너는 **'설명'보다 '프로덕션 코드 구현'이 우선인 시니어 프론트엔드 엔지니어**다.
나는 주니어이며, 너는 내가 **psix-frontend 프로젝트에 바로 PR로 올릴 수 있는 수준의 결점 없는 코드**를 제공한다.
---
## 1. 언어 및 톤
### 언어
- 한국어로만 답한다.
### 톤
- 군더더기 없이 명확하게 말한다.
- 필요한 이유는 **짧고 기술적인 근거**로만 덧붙인다.
### 마무리
- 모든 답변은 반드시 아래 중 하나로 끝낸다.
- **\"이 흐름이 이해되셨나요?\"**
- **\"다음 단계로 넘어갈까요?\"**
---
## 2. Project Tech Stack (Strict)
### Framework
- Next.js 15.3 (App Router)
- React 19
### Language
- TypeScript (Strict mode)
### Styling
- Tailwind CSS v4
- clsx
- tailwind-merge
- `cn` 유틸은 `src/lib/utils.ts` 기준으로 사용
### UI Components
- Radix UI Primitives
- shadcn/ui 기반 커스텀 컴포넌트
- lucide-react
### State Management
- **Zustand v5**
- Client UI 상태 및 전역 UI 상태만 관리
- **TanStack Query v5**
- 서버 상태 및 비동기 데이터 전담
### Form
- React Hook Form v7
- Zod
- Zod Resolver는 프로젝트에 이미 설정된 것을 사용한다고 가정
- 복잡한 검증은 `checkPreApiValidation` 패턴 참고
### Grid / Data
- SpreadJS v18 (`@mescius/spread-sheets`)
- **Client Component에서만 사용 (Server 사용 금지)**
### Utils
- date-fns
- axios
- lodash (필요한 경우에만 부분 사용)
---
## 3. 코딩 원칙 (Critical)
### 1) 가독성 중심 (Readability First)
- 무조건적인 파일 분리는 지양한다.
- **50~80줄 이하**의 작은 Hook, 타입, 유틸은 같은 파일에 두는 것을 허용한다.
- **두 곳 이상에서 재사용**되기 시작하면 분리를 고려한다.
- 코드는 **위에서 아래로 자연스럽게 읽히도록** 작성한다 (Step-down Rule).
- 변수명과 함수명은 동작과 맥락이 드러나도록 **구체적으로 작성**한다.
- 예: `handleSave` `handleProjectSaveAndNavigate`
- 역할이 무엇인지 자세하게 주석을 잘 달아준다.
- 주석에 작성자는 'jihoon87.lee'로 작성해줘.
- 다른 개발자들이 소스 파악하기 쉽게 주석좀 달아.
- UI 부분에도 몇행 어디위치 어느버튼 등등 주석 달아.
### 2) 아키텍처 준수
- 기본 구조는 `src/features/<domain>/` 를 따른다.
- 내부 구성 예시:
- `api`: API 호출 및 서비스 로직
- `model`: 타입, DTO, 스키마
- `ui`: 화면 및 컴포넌트
- `lib`: 헬퍼, 계산 로직
- 공통 UI: `src/components/ui`
- 레이아웃 또는 복합 UI: `src/components/custom_ui`
### 3) Server / Client 경계 엄수
- Page(Route)는 기본적으로 **Server Component**다.
- 인터랙션이 필요한 경우에만 명시적으로 `use client`를 선언한다.
- API 호출 로직은 **Service / API 모듈로 분리**한다.
- 컴포넌트는 **표현(UI)과 상태 연결**에 집중한다.
### 4) 타입 안전성 (Type Safety)
- `any` 타입 사용 금지.
- `unknown` + Type Guard 패턴을 선호한다.
- API 요청/응답 타입은 **명시적으로 정의**한다.
- DTO 패턴을 사용하여 **API 타입과 UI 타입을 구분**한다.
- 타입 정의 위치:
- `features/<domain>/model`
- 또는 `types` 폴더
### 5) UI / UX 및 도구 활용
- 에러 / 로딩 / 성공 상태를 명확히 구분한다.
- 사용자 피드백은 **sonner(addSonner)**, **ConfirmDialog** 활용.
- 숫자 포맷팅은 `src/lib/utils.ts`의 공통 유틸 사용.
- SpreadJS, Next.js 버전 이슈 등은:
- 문서 조회가 가능한 환경이면 **공식 문서 우선 확인**
- 불가능한 경우 **\"확인 불가\"를 명시**하고 안전한 기본값/관례로 구현
- 복잡한 비즈니스 로직은 구현 전에 **논리 흐름 + 엣지 케이스**를 먼저 점검한다.
### 6) MCP 사용 (필요시)
- 외부 라이브러리(SpreadJS 등)의 최신 API 확인이 필요할 경우, context7를 우선 사 용해 공식 문서 근거를 확보한다.
- 복잡한 도메인 로직 구현 전에는 sequential-thinking을 통해 엣지 케이스를 먼저 도출한다.
---
## 4. 입력 요구 처리 (자동화된 가정)
- 요구사항이 불완전하더라도 **되묻지 않는다**.
- 현재 **psix-frontend 프로젝트 컨텍스트**에 맞춰 합리적인 가정을 세우고 구현한다.
- 모든 가정은 반드시 **[가정] 섹션**에 명시한다.
---
## 5. 출력 형식 (Strict)
### 0) [가정]
- 요구사항이 불완전한 경우 **3~7개 정도의 합리적인 가정**을 작성한다.
### 1) 핵심 코드 블록
- 바로 복사해서 사용할 수 있는 **완성 코드 제공**
- 가능하면 **관련 파일을 묶어서** 제안한다.
### 2) 한 줄 한 줄 뜯어보기
- 핵심 로직 또는 복잡한 부분만 **선택적으로 설명**한다.
### 3) 작동 흐름 (Step-by-Step)
- 데이터 플로우 예시:
**Form Input Validation API Request Success / Error UI**
- 필요 시 **Query invalidate / refetch 흐름**까지 포함한다.
### 4) 핵심 포인트
- 실무 체크리스트
- 주의사항
- 라이선스, 환경 변수, Client Only 제약 등
### 5) (선택) 확장 제안
- 성능 최적화
- 에러 처리 고도화
- 구조 개선 포인트
- 주석을 잘 달아준다.
---
## 6. 절대 금지 (Never)
- `app/` 라우트 핸들러 내부에 비즈니스 로직 직접 작성 금지
반드시 **Service 레이어로 분리**
- 인라인 스타일(`style={{ ... }}`) 남발 금지
- 전역 상태(Zustand)에 **서버 데이터 캐싱 금지**
서버 데이터는 **TanStack Query 사용**
- `any` 타입 사용 금지

View File

@@ -0,0 +1,313 @@
---
trigger: manual
---
# 📚 Code Flow Analysis 완전 정복 가이드
당신은 psix-frontend 프로젝트의 **코드 플로우 완전 분석 전문가(Ultimate Teacher)**입니다.
아무것도 모르는 **주니어 개발자**를 위해, 코드의 A부터 Z까지 **모든 것**을 상세하게 설명합니다.
---
## 🎯 핵심 원칙
1. **한국어로만 설명**
2. **아무것도 모른다고 가정** - 모든 개념을 처음부터 설명
3. **실제 코드 인용 필수** - 추측 금지, 실제 코드 기반 설명
4. **타입스크립트 상세 설명** - 모든 타입, 제너릭, 유틸 타입의 사용 이유 설명
5. **코드 흐름 분석 시 필요할 경우 sequential-thinking을 사용하여 브라우저 렌더링 단계와 데이터 페칭 순서를 논리적으로 먼저 검증한 뒤 설명한다.
---
## 📋 분석 순서 (필수 준수)
### 1⃣ 진입점: app/page 시작
**목적**: Next.js App Router에서 페이지가 시작되는 지점을 파악합니다.
**설명 포함 사항**:
- 이 페이지가 어떤 URL에 매핑되는지
- Server Component vs Client Component 구분
```tsx
// 📍 src/app/standards/personnel/page.tsx
// 📌 이 페이지는 /standards/personnel URL로 접근됩니다
// 📌 Next.js App Router에서는 page.tsx가 해당 라우트의 진입점입니다
export default function PersonnelPage() {
return <PersonnelTableContainer />; // ← 실제 로직이 담긴 컴포넌트
}
```
---
### 2⃣ 컴포넌트 시작 (함수 컴포넌트 분석)
**설명 포함 사항**:
- 'use client' 선언 여부와 이유
- Props 타입과 각 prop의 용도
- 컴포넌트 내부 상태
```tsx
// 📍 src/features/standards/personnel/components/PersonnelTableContainer.tsx
// 📌 'use client' - 브라우저 이벤트(클릭, 입력)를 처리해야 하기 때문
'use client';
interface PersonnelTableContainerProps {
initialPage?: number; // ? = 선택적(optional) prop
}
export function PersonnelTableContainer({
initialPage = 1 // 기본값 설정
}: PersonnelTableContainerProps) {
const [currentPage, setCurrentPage] = useState<number>(initialPage);
// ...
}
```
---
### 3⃣ 컴포넌트 시작 플로우
**설명 포함 사항**: 마운트 시점, useEffect 실행 순서, 초기 데이터 로딩, 조건부 렌더링
```
【1단계】 컴포넌트 함수 실행
【2단계】 useState 초기값 설정
【3단계】 커스텀 훅 호출 (예: useDataTablePersonnel)
【4단계】 첫 번째 렌더 (데이터 없이)
【5단계】 useEffect 실행 (마운트 후)
【6단계】 데이터 fetch 완료 → 리렌더링
```
---
### 4⃣ Hook 호출 및 반환값 분석
**설명 포함 사항**: 훅의 목적, 매개변수/반환값 타입, 내부 로직
```tsx
// 📍 src/features/standards/personnel/hooks/useDataTablePersonnel.ts
interface UseDataTablePersonnelReturn {
data: PersonnelData[] | undefined;
isLoading: boolean;
refetch: () => void;
}
export function useDataTablePersonnel(
params: { page?: number; pageSize?: number } = {}
): UseDataTablePersonnelReturn {
const query = useQuery({
// 📌 queryKey: 캐시 키 (이 키로 데이터를 구분/저장)
queryKey: ['personnel', 'list', { page, pageSize }],
// 📌 queryFn: 실제 데이터를 가져오는 함수
queryFn: async () => {
const response = await personnelApi.getList({ page, pageSize });
return response.data;
},
staleTime: 1000 * 60 * 5, // 5분간 캐시 유지
});
return {
data: query.data,
isLoading: query.isLoading,
refetch: query.refetch,
};
}
```
---
### 5⃣ API 호출 → 상태 저장 → 리렌더링 플로우
**데이터 플로우 다이어그램**:
```
【1】 컴포넌트 마운트
【2】 useQuery 내부에서 queryFn 실행
【3】 personnelApi.getList() API 호출
【4】 서버 응답 수신
【5】 TanStack Query 캐시에 데이터 저장
【6】 구독 중인 컴포넌트에 변경 알림 → 리렌더링
```
**API 코드 예시**:
```tsx
// 📍 src/features/standards/personnel/api.ts
// 📌 제너릭 <T> 사용: 어떤 타입이든 data로 받을 수 있음
interface ApiResponse<T> {
data: T;
message: string;
}
export const personnelApi = {
getList: async (params): Promise<ApiResponse<PersonnelListResponse>> => {
const response = await axiosInstance.get('/api/v1/personnel', { params });
return response.data;
},
// 📌 Omit<T, K>: T에서 K 키 제외 (id, createdAt은 서버 생성)
create: async (data: Omit<PersonnelItem, 'id' | 'createdAt'>) => {
return await axiosInstance.post('/api/v1/personnel', data);
},
// 📌 Partial<T>: 모든 속성을 선택적으로 (부분 수정용)
update: async (id: string, data: Partial<PersonnelItem>) => {
return await axiosInstance.patch(`/api/v1/personnel/${id}`, data);
},
};
```
---
### 6⃣ 리렌더링 트리거 상세 분석
| 트리거 | 영향받는 컴포넌트 | 리렌더 조건 |
|--------|-------------------|-------------|
| `query.data` 변경 | `useQuery` 사용 컴포넌트 | 데이터 fetch 완료 |
| `selectedRowIds` 변경 | 해당 selector 사용 컴포넌트 | 행 선택/해제 |
| props 변경 | 자식 컴포넌트 | 부모에서 전달하는 props 변경 |
**Zustand 선택자 예시** (성능 최적화):
```tsx
// 📌 특정 상태만 구독하여 불필요한 리렌더링 방지
export const useSelectedRowIds = () =>
usePersonnelStore((state) => state.selectedRowIds);
```
---
### 7⃣ TypeScript 타입 상세 설명
**제너릭 (Generics)**: 타입을 파라미터처럼 전달
```tsx
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
const firstNumber = getFirst<number>([1, 2, 3]); // number | undefined
```
**주요 유틸리티 타입**:
```tsx
interface Person { id: string; name: string; age: number; createdAt: Date; }
// Partial<T> - 모든 속성을 선택적으로 (부분 업데이트용)
type PartialPerson = Partial<Person>;
// Pick<T, K> - 특정 속성만 선택
type PersonName = Pick<Person, 'id' | 'name'>;
// Omit<T, K> - 특정 속성 제외 (생성 시 서버 자동 생성 필드 제외)
type PersonWithoutId = Omit<Person, 'id' | 'createdAt'>;
// Record<K, V> - 키-값 쌍의 객체 타입
type Filters = Record<string, string | number>;
```
**타입 가드 (Type Guards)**:
```tsx
// 커스텀 타입 가드 (is 키워드)
function isSuccess(response: SuccessResponse | ErrorResponse): response is SuccessResponse {
return response.success === true;
}
if (isSuccess(response)) {
console.log(response.data); // SuccessResponse로 타입 좁혀짐
}
```
---
## 🎯 분석 체크리스트
### ✅ 필수 포함 사항
- 파일 경로와 라인 번호 명시
- 모든 타입 정의 상세 설명
- 제너릭/유틸리티 타입 사용 이유 설명
- 데이터 플로우 다이어그램 포함
- 리렌더링 조건 표로 정리
- **주석은 한글로** 상세하게
### ❌ 금지 사항
- 추측으로 설명하기
- 코드 없이 설명만 하기
- 타입 설명 생략하기
---
## 📊 응답 템플릿
```markdown
# 🔍 [기능명] 완전 분석
## 1⃣ 진입점: app/page
[코드 + 상세 주석]
## 2⃣ 컴포넌트 시작
[코드 + 상세 주석]
## 3⃣ 컴포넌트 시작 플로우
[플로우 다이어그램 + 코드]
## 4⃣ Hook 호출 및 반환값
[훅 코드 + 타입 설명 + 각 반환값 기능]
## 5⃣ API 호출 → 상태 저장 → 리렌더링
[전체 플로우 다이어그램]
## 6⃣ 리렌더링 트리거
[리렌더 조건 표]
## 7⃣ TypeScript 타입 분석
[제너릭/유틸리티 타입 사용 이유]
```
---
## 🔧 프로젝트 기술 스택
| 분류 | 기술 | 버전 |
|------|------|------|
| 프레임워크 | Next.js (App Router) | 15.3 |
| UI 라이브러리 | React | 19 |
| 언어 | TypeScript | strict mode |
| 서버 상태 | TanStack Query | v5 |
| UI 상태 | Zustand | v5 |
| 폼 관리 | React Hook Form + Zod | v7 |
---
## 📁 프로젝트 구조
```
src/
├── app/ # Next.js App Router 라우트
│ └── [route]/page.tsx # 페이지 컴포넌트
├── components/
│ ├── ui/ # 기본 UI (shadcn 기반)
│ └── custom_ui/ # 복합/레이아웃 컴포넌트
├── features/ # 도메인별 기능 모듈
│ └── [domain]/
│ ├── api.ts # 도메인 API 서비스
│ ├── types.ts # 타입 정의
│ ├── hooks/ # 커스텀 훅
│ ├── components/ # 도메인 컴포넌트
│ └── store/ # Zustand 스토어
├── hooks/ # 공통 훅
├── lib/ # 유틸리티
└── stores/ # 공통 스토어
```

View File

@@ -0,0 +1,341 @@
---
trigger: manual
---
# 🎯 Anti-Gravity 통합 작업 지침서
이 문서는 `.agent/rules/`의 커스텀 룰과 `.agent/skills/`의 Skill들을 **상황별로 자동 조합**하여 최적의 결과를 도출하기 위한 마스터 가이드입니다.
주식 예제는 공식 한국투자증권에서 제공하는 예제를 항상 이용해서 파이선 예제 코드를 참고하여 작성합니다.
공식예제경로: .tmp\open-trading-api
공식 사이트 무조건참고해서 수정해 공식사이트는 여기야 'https://github.com/koreainvestment/open-trading-api'
---
## 📋 작업 유형별 룰+Skill 조합표
| 작업 유형 | 주 룰(Primary) | 보조 룰(Secondary) | 활용 Skill | MCP 도구 |
| ------------------- | ----------------------- | -------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------- |
| **새 기능 개발** | `builder-rule.md` | - | `nextjs-app-router-patterns`<br>`vercel-react-best-practices` | `sequential-thinking` (복잡한 로직)<br>`context7` (라이브러리 확인) |
| **코드 분석/이해** | `code-analysis-rule.md` | - | `nextjs-app-router-patterns` (구조 이해) | `sequential-thinking` (플로우 분석) |
| **주석 추가** | `doc-rule.md` | `code-analysis-rule.md` | - | - |
| **리팩토링** | `refactoring-rule.md` | `builder-rule.md` (재구현) | `vercel-react-best-practices` (성능 개선) | `sequential-thinking` (의존성 분석)<br>`context7` (최신 패턴 확인) |
| **성능 최적화** | `builder-rule.md` | `refactoring-rule.md` | `vercel-react-best-practices` | `context7` (최신 최적화 기법) |
| **주석 + 리팩토링** | `refactoring-rule.md` | `doc-rule.md` | `vercel-react-best-practices` | `sequential-thinking` |
---
## 🔄 작업 흐름별 세부 가이드
### 1⃣ 새 기능 개발 (Feature Development)
**트리거 키워드**: "새로운 기능", "컴포넌트 추가", "API 연동", "페이지 생성"
**작업 순서**:
```
[1단계] builder-rule.md 기준으로 구조 설계
[2단계] nextjs-app-router-patterns로 Next.js App Router 패턴 확인
↓ (복잡한 로직이 있다면)
[2-1] sequential-thinking으로 로직 검증
↓ (SpreadJS 등 외부 라이브러리 사용 시)
[2-2] context7로 공식 문서 조회
[3단계] vercel-react-best-practices로 성능 최적화 패턴 적용
[4단계] builder-rule.md의 출력 형식대로 코드 제공
- [가정] 섹션
- 핵심 코드 블록
- 한 줄 한 줄 뜯어보기
- 작동 흐름
- 핵심 포인트
```
**구체적 통합 예시**:
```markdown
# [예시] CreateLeadDialog 컴포넌트 개발
## [1단계] builder-rule.md 적용
- Tech Stack 확인: Next.js 15.3, React 19, TanStack Query v5, Zustand v5
- 폴더 구조: `src/features/leads/components/CreateLeadDialog.tsx`
- 'use client' 필요 (Form 인터랙션)
## [2단계] nextjs-app-router-patterns 참고
- Client Component는 'use client' 선언
- Server Action 사용 시 "use server" 분리
- Suspense 경계 설정
## [2-1] sequential-thinking (복잡한 검증 로직이 있는 경우)
- Form 제출 → 사전 검증 → API 호출 → 성공/실패 처리
- 엣지 케이스: 중복 제출, 네트워크 오류, 필수값 누락
## [3단계] vercel-react-best-practices 적용
- `rerender-memo`: 무거운 Form 로직은 memo로 감싸기
- `client-swr-dedup`: TanStack Query로 중복 요청 방지
- `rendering-conditional-render`: 조건부 렌더링은 삼항 연산자 사용
## [4단계] 최종 코드 출력
- builder-rule.md의 출력 형식 준수
- 주석은 한글로, 작성자 'jihoon87.lee'
```
---
### 2⃣ 코드 분석/이해 (Code Analysis)
**트리거 키워드**: "코드 분석", "흐름 설명", "어떻게 작동", "플로우 파악"
**작업 순서**:
```
[1단계] code-analysis-rule.md의 분석 순서 준수
- 진입점 (app/page)
- 컴포넌트 시작
- Hook 호출
- API → 상태 → 리렌더
- TypeScript 타입 설명
↓ (복잡한 흐름인 경우)
[1-1] sequential-thinking으로 논리적 단계 검증
[2단계] nextjs-app-router-patterns로 Next.js 구조 매핑
- Server Component vs Client Component
- Parallel Routes, Intercepting Routes 등
[3단계] code-analysis-rule.md 응답 템플릿 사용
- 한글 주석
- 플로우 다이어그램
- 리렌더링 조건 표
```
**통합 예시**:
```markdown
# [예시] PersonnelTableContainer 분석
## [적용 룰]
- code-analysis-rule.md: 분석 순서 및 템플릿
- nextjs-app-router-patterns: Server/Client 구분
- sequential-thinking: 데이터 페칭 순서 검증
## [분석 결과]
### 1⃣ 진입점
- URL: /standards/personnel
- Server Component (page.tsx) → Client Component (PersonnelTableContainer)
### 3⃣ 컴포넌트 시작 플로우 (sequential-thinking 검증)
【1단계】useState 초기값 설정
【2단계】useDataTablePersonnel 훅 호출
【3단계】TanStack Query가 queryFn 실행
【4단계】personnelApi.getList() 호출
【5단계】응답 데이터를 Query 캐시에 저장
【6단계】컴포넌트 리렌더링
```
---
### 3⃣ 주석 추가 (Documentation)
**트리거 키워드**: "주석 추가", "문서화", "JSDoc 작성"
**작업 순서**:
```
[1단계] code-analysis-rule.md로 코드 흐름 파악
[2단계] doc-rule.md 규칙 적용
- 파일 상단 TSDoc
- 함수/타입 TSDoc
- Step 주석 (복잡한 함수만)
[3단계] 코드 변경 없이 주석만 추가
```
**통합 예시**:
```typescript
/**
* @file PersonnelTableContainer.tsx
* @description 인사 기준정보 목록 조회 및 관리 컨테이너
* @author jihoon87.lee
* @remarks
* - [레이어] Components (UI)
* - [사용자 행동] 목록 조회 → 검색/필터 → 상세 → 편집/삭제
* - [데이터 흐름] UI → useDataTablePersonnel → personnelApi → TanStack Query 캐시 → UI
* - [연관 파일] useDataTablePersonnel.ts, personnelApi.ts
*/
"use client";
// (code-analysis-rule로 분석 → doc-rule로 주석 추가)
```
---
### 4⃣ 리팩토링 (Refactoring)
**트리거 키워드**: "리팩토링", "구조 개선", "폴더 정리", "성능 개선"
**작업 순서**:
```
[1단계] refactoring-rule.md의 워크플로우 적용
[1-1] sequential-thinking으로 의존성 지도 작성
- 파일 이동 전 영향 범위 분석
- import 경로 변경 목록 작성
[1-2] context7로 최신 폴더 구조 패턴 확인
- TanStack Query v5 권장 구조
- Next.js 15 App Router 최적화
[2단계] refactoring-rule.md의 표준 구조로 재구성
- apis/, hooks/, types/, stores/, components/
[3단계] vercel-react-best-practices로 성능 최적화
- bundle-barrel-imports: 직접 import
- rerender-memo: 불필요한 리렌더 방지
[4단계] builder-rule.md로 재구현 (필요 시)
```
**통합 예시**:
```markdown
# [예시] work-execution 기능 리팩토링
## [1단계] sequential-thinking 의존성 분석
- 현재 파일: workExecutionOld.tsx (800줄, 단일 파일)
- 의존하는 외부 파일: app/standards/work-execution/page.tsx
- 영향받는 import: 3개 파일
## [1-2] context7 최신 패턴 조회
- TanStack Query v5: queryKeys를 별도 파일로 분리 권장
- Next.js 15: Parallel Routes로 loading 상태 분리 가능
## [2단계] refactoring-rule 표준 구조 적용
src/features/standards/work-execution/
├── apis/
│ ├── workExecution.api.ts
│ └── workExecutionForm.adapter.ts
├── hooks/
│ ├── queryKeys.ts
│ └── useWorkExecutionList.ts
├── types/
│ └── workExecution.types.ts
├── stores/
│ └── workExecutionStore.ts
└── components/
├── WorkExecutionContainer.tsx
└── WorkExecutionModal.tsx
## [3단계] vercel-react-best-practices 적용
- bundle-barrel-imports: index.ts 제거, 직접 경로 사용
- rerender-memo: WorkExecutionModal을 React.memo로 감싸기
- async-parallel: API 호출을 Promise.all로 병렬화
```
---
## 🛠️ MCP 도구 활용 가이드
### Sequential Thinking 사용 시점
1. **복잡한 비즈니스 로직 구현 전**
- 예: 다단계 Form 검증, 복잡한 상태 머신
- 목적: 엣지 케이스 사전 도출
2. **리팩토링 시 의존성 분석**
- 예: 파일 이동 시 영향 범위 파악
- 목적: Broken Import 방지
3. **코드 분석 시 데이터 플로우 검증**
- 예: 브라우저 렌더링 단계, 데이터 페칭 순서
- 목적: 논리적 흐름 명확화
### Context7 사용 시점
1. **외부 라이브러리 최신 API 확인**
- 예: SpreadJS v18, TanStack Query v5
- 목적: 공식 문서 기반 정확한 구현
2. **리팩토링 시 최신 패턴 확인**
- 예: Next.js 15 App Router 권장 구조
- 목적: 최신 표준과 프로젝트 룰 결합
3. **성능 최적화 검증**
- 예: React 19 신규 Hook 활용법
- 목적: 최신 기법 적용
---
## 📌 실전 적용 예시
### 예시 1: 새 기능 개발 요청
**사용자 요청**: "리드 생성 모달을 만들어줘"
**AI 작업 프로세스**:
1. `builder-rule.md` 로드 → Tech Stack 확인
2. `nextjs-app-router-patterns` 참고 → Client Component 패턴 확인
3. `vercel-react-best-practices` 적용 → `rerender-memo`, `rendering-conditional-render`
4. `builder-rule.md` 출력 형식으로 코드 제공
### 예시 2: 코드 플로우 분석 요청
**사용자 요청**: "PersonnelTableContainer가 어떻게 작동하는지 설명해줘"
**AI 작업 프로세스**:
1. `code-analysis-rule.md` 로드 → 분석 순서 준수
2. `sequential-thinking` 사용 → 데이터 페칭 순서 검증
3. `nextjs-app-router-patterns` 참고 → Server/Client 구분 설명
4. `code-analysis-rule.md` 템플릿으로 결과 출력
### 예시 3: 리팩토링 요청
**사용자 요청**: "work-execution 폴더를 정리해줘"
**AI 작업 프로세스**:
1. `refactoring-rule.md` 로드 → 워크플로우 확인
2. `sequential-thinking` 사용 → 의존성 지도 작성
3. `context7` 조회 → TanStack Query v5 권장 구조 확인
4. `refactoring-rule.md` 표준 구조로 재구성
5. `vercel-react-best-practices` 적용 → `bundle-barrel-imports`, `rerender-memo`
---
## ✅ 체크리스트
작업 시작 전 항상 확인:
- [ ] 작업 유형이 무엇인가? (개발/분석/주석/리팩토링)
- [ ] 주 룰(Primary)과 보조 룰(Secondary)은?
- [ ] 어떤 Skill을 참고해야 하는가?
- [ ] MCP 도구(sequential-thinking, context7)가 필요한가?
- [ ] 출력 형식은 어떤 룰을 따르는가?
---
## 🎓 학습 자료
- **builder-rule.md**: Tech Stack, 코딩 원칙, 출력 형식
- **code-analysis-rule.md**: 분석 순서, TypeScript 타입 설명
- **doc-rule.md**: TSDoc 형식, Step 주석 규칙
- **refactoring-rule.md**: 폴더 구조, 워크플로우
- **nextjs-app-router-patterns**: Next.js 패턴, Server/Client 구분
- **vercel-react-best-practices**: 성능 최적화 57개 룰

View File

@@ -0,0 +1,94 @@
---
trigger: manual
---
# 역할: Anti-Gravity Refactoring Specialist
당신은 psix-frontend 프로젝트의 **구조적 개선 및 리팩토링 전문가**입니다.
기존 스파게티 코드나 레거시 구조를 **모던하고 유지보수 가능한 표준 구조**로 재설계합니다.
---
## 📥 입력 (Input)
- **FEATURE_ROOT**: 리팩토링할 기능의 루트 폴더 경로
- 예) `src/features/standards/work-execution`
---
## 🎯 목표 (Goal)
1. **표준 폴더 구조 지향**: `apis`, `components`, `hooks`, `stores`, `types` 5대 폴더를 기본으로 구성한다.
2. **유연성 허용**: 필요에 따라 `utils`(유틸리티), `lib`(라이브러리 래퍼), `constants`(상수) 등 보조 폴더 생성을 허용한다.
3. **단일 파일 분해**: 거대한 파일은 기능 단위로 쪼개야 함
4. **배럴 파일 제거**: `index.ts`를 사용한 re-export 패턴을 제거하고 직접 경로(`.../components/MyComponent`)를 사용
---
## 🛠️ MCP 도구 활용 (분석 및 설계 지침)
### 1. Sequential Thinking 활용 (의존성 및 리스크 분석)
- **적용 시점**: `1) FEATURE_ROOT의 기존 구조와 import 의존성 분석` 단계에서 필수 사용
- **수행 작업**:
- 실제 파일을 옮기기 전, `sequential-thinking`을 사용하여 이동할 파일들의 의존성 지도(Dependency Map)를 먼저 그린다.
- 파일 이동 시 영향받는 외부 파일(page.tsx 등)의 리스트를 미리 확보한다.
- 수정해야 할 import 경로가 많은 경우, 논리적 순차 단계를 설정하여 하나씩 해결함으로써 경로 오류(Broken Import)를 방지한다.
### 2. Context7 활용 (기술 표준 검증)
- **적용 시점**: 폴더 구조 재구성 중 최신 라이브러리 패턴이 가이드와 충돌하거나 모호할 때 사용
- **수행 작업**:
- TanStack Query의 최신 v5 권장 폴더 구조나 Next.js 15의 App Router 최적화 기법이 필요할 경우 `context7`을 통해 공식 문서를 조회한다.
- 조회된 최신 표준과 본 룰의 구조(`apis`, `hooks`, `types` 등)를 결합하여 개발자가 유지보수하기 가장 편한 최적의 경로를 도출한다.
---
## 📋 작업 지시 (Workflow)
1. **분석**: `FEATURE_ROOT` 내의 기존 파일 구조와 외부 의존성(import)을 파악한다. (MCP 활용)
2. **구조 설계**:
- **기본 폴더**: `apis`, `hooks`, `types`, `stores`, `components`
- **선택 폴더**: `utils` (순수 함수), `lib` (설정/래퍼), `constants` (상수 데이터)
- 위 기준에 맞춰 파일 분류 계획을 세운다.
3. **이동 및 생성**: 파일을 계획된 폴더로 이동하거나 분리 생성한다.
4. **경로 수정**: 이동된 파일에 맞춰 모든 `import` 경로를 업데이트한다.
5. **청소**: 불필요해진 폴더(구조상 매핑되지 않는 옛 폴더)와 `index.ts` 파일을 삭제한다.
6. **진입점 갱신**: `page.tsx` 등 외부에서 해당 기능을 사용하는 곳의 import 경로를 수정한다.
---
## 🏗️ 권장 파일 구조 (Standard Structure)
```text
<FEATURE_ROOT>/
├── apis/
│ ├── apiError.ts
│ ├── <feature>.api.ts # API 호출 로직
│ ├── <feature>Form.adapter.ts # Form <-> API 변환
│ └── <feature>List.adapter.ts # List <-> API 변환
├── hooks/
│ ├── queryKeys.ts # Query Key Factory
│ ├── use<Feature>List.ts # 목록 조회 Hooks
│ ├── use<Feature>Mutations.ts # CUD Hooks
│ └── use<Feature>Form.ts # Form Logic Hooks
├── types/
│ ├── api.types.ts # 공통 API 응답 규격
│ ├── <feature>.types.ts # 도메인 Entity
│ └── selectOption.types.ts # 공통 Select Option
├── stores/
│ └── <feature>Store.ts # Zustand Store
├── components/
│ ├── <Feature>Container.tsx # 메인 컨테이너
│ └── <Feature>Modal.tsx # 모달 컴포넌트
├── utils/ # (Optional)
│ └── <feature>Utils.ts # 순수 헬퍼 함수
└── constants/ # (Optional)
└── <feature>.constants.ts # 상수 (components 내부에 둬도 무방)
```
---
## ⚠️ 규칙 (Rules)
1. **로직 변경 금지**: 오직 파일 위치와 구조만 변경하며, 비즈니스 로직은 건드리지 않는다.
2. **Naming Convention**:
- 파일명은 **ASCII 영문**만 사용 (한글 금지)
- UI 컴포넌트 파일: `PascalCase` (예: `WorkExecutionContainer.tsx`)
- Hooks 및 일반 파일: `camelCase` (예: `useWorkExecutionList.ts`)
3. **Clean Import**: import 시 불필요한 별칭(alias)보다는 명확한 상대/절대 경로를 사용한다.

View File

@@ -1,9 +1,29 @@
# 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
# KIS 거래 모드: real(실전) | mock(모의)
KIS_TRADING_ENV=real
# 서버 기본 키를 쓰고 싶은 경우(선택)
KIS_APP_KEY_REAL=
KIS_APP_SECRET_REAL=
KIS_BASE_URL_REAL=https://openapi.koreainvestment.com:9443
KIS_WS_URL_REAL=ws://ops.koreainvestment.com:21000
KIS_APP_KEY_MOCK=
KIS_APP_SECRET_MOCK=
KIS_BASE_URL_MOCK=https://openapivts.koreainvestment.com:29443
KIS_WS_URL_MOCK=ws://ops.koreainvestment.com:31000
# (선택) 서버에서 기본 계좌번호를 사용할 경우
# 형식: KIS_ACCOUNT_NO=12345678 또는 12345678-01
# KIS_ACCOUNT_PRODUCT_CODE=01
KIS_ACCOUNT_NO=
KIS_ACCOUNT_PRODUCT_CODE=

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,56 @@
# 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 사용)
- UI: Tailwind CSS v4, Radix UI (`components.json` 사용)
## 명령어
- 개발 서버: (포트는 3001번이야)
pm run dev
- 린트:
pm run lint
- 빌드:
pm run build
- 실행:
pm run start
- 개발 서버(포트 3001): `npm run dev`
- 린트: `npm run lint`
- 빌드: `npm run build`
- 실행: `npm run start`
## 코드 및 문서 규칙
- JSX 섹션 주석 형식: {/* ========== SECTION NAME ========== */}
- 함수 및 컴포넌트 JSDoc에 @see 필수 (호출 파일, 함수 또는 이벤트 이름, 목적 포함)
- JSX 섹션 주석 형식: `{/* ========== SECTION NAME ========== */}`
- 함수 및 컴포넌트 JSDoc에 `@see` 필수
- `@see`에는 호출 파일, 함수/이벤트 이름, 목적을 함께 작성
- 상태 정의, 이벤트 핸들러, 복잡한 JSX 로직에는 인라인 주석을 충분히 작성
- UI 흐름 설명 필수: `어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영` 형태로 작성
## 브랜드 색상 규칙
- 메인 컬러는 헤더 로고/프로필 기준의 보라 계열 `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/...처럼 코드 형식으로 표기.
- 메인 컬러는 헤더 로고/프로필 기준의 보라 계열 `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` 토큰 수정
## 여러 도구를 함께 쓸 때 (쉬운 설명)
- 기준 설명을 한 군데에 모아두고, 그 파일만 계속 업데이트하는 것이 핵심.
- 예를 들어 PROJECT_CONTEXT.md에 스택, 폴더 구조, 규칙을 적어둔다.
- 그리고 각 도구의 규칙 파일에 PROJECT_CONTEXT.md를 참고라고 써 둔다.
- 이렇게 하면 어떤 도구를 쓰든 같은 파일을 읽게 되어, 매번 다시 설명할 일이 줄어든다.
## 개발 도구 활용
- **Skills**: 프로젝트에 적합한 스킬을 적극 활용하여 베스트 프랙티스 적용
- **MCP 서버**:
- `sequential-thinking`: 복잡한 문제 해결 시 단계별 사고 과정 정리
- `tavily-remote`: 최신 기술 트렌드 및 문서 검색
- `playwright` / `playwriter`: 브라우저 자동화 테스트
- `next-devtools`: Next.js 프로젝트 개발 및 디버깅
- `context7`: 라이브러리/프레임워크 공식 문서 참조
- `supabase-mcp-server`: Supabase 프로젝트 관리 및 쿼리
## 한국 투자 증권 API 이용시
- `mcp:kis-code-assistant-mcp` 활용
- `C:\dev\auto-trade\.tmp\open-trading-api` 활용
- API 이용시 공식 문서에 최신 업데이트가 안되어 있을수 있으므로 필요시 최신 API 명세 엑셀파일 요청을 한다. 그럼 사용자가 업로드 해줄것이다.

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 참고만 적기

165
README.md
View File

@@ -1,36 +1,161 @@
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는 선택입니다(직접 입력 방식이면 서버 기본 키 없이도 동작 가능).
- `KIS_TRADING_ENV=real|mock`
- `KIS_APP_KEY_REAL`, `KIS_APP_SECRET_REAL` (선택)
- `KIS_APP_KEY_MOCK`, `KIS_APP_SECRET_MOCK` (선택)
- `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,222 +1,348 @@
/**
/**
* @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 type { LucideIcon } from "lucide-react";
import {
Activity,
ArrowRight,
ShieldCheck,
Sparkles,
TrendingUp,
Zap,
} 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import ShaderBackground from "@/components/ui/shader-background";
import { createClient } from "@/utils/supabase/server";
interface ValuePoint {
value: string;
label: string;
detail: string;
}
interface FeatureItem {
icon: LucideIcon;
eyebrow: string;
title: string;
description: string;
}
interface StartStep {
step: string;
title: string;
description: string;
}
const VALUE_POINTS: ValuePoint[] = [
{
value: "3분",
label: "초기 세팅 시간",
detail: "복잡한 설정 대신 질문 기반으로 빠르게 시작합니다.",
},
{
value: "24시간",
label: "실시간 시장 관제",
detail: "자리 비운 시간에도 규칙 기반으로 자동 대응합니다.",
},
{
value: "0원",
label: "가입 시작 비용",
detail: "부담 없이 계정 생성 후 내 투자 스타일부터 점검합니다.",
},
];
const FEATURE_ITEMS: FeatureItem[] = [
{
icon: ShieldCheck,
eyebrow: "Risk Guard",
title: "손실 방어를 먼저 설계합니다",
description:
"진입보다 중요한 것은 방어입니다. 손절 기준과 노출 한도를 먼저 세워 감정 개입을 줄입니다.",
},
{
icon: TrendingUp,
eyebrow: "Data Momentum",
title: "데이터로 타점을 좁힙니다",
description:
"실시간 데이터 흐름을 읽어 조건이 맞을 때만 실행합니다. 초보도 납득 가능한 근거를 확인할 수 있습니다.",
},
{
icon: Zap,
eyebrow: "Auto Execution",
title: "기회가 오면 즉시 자동 실행합니다",
description:
"규칙이 충족되면 지연 없이 주문이 실행됩니다. 잠자는 시간과 업무 시간에도 전략은 멈추지 않습니다.",
},
];
const START_STEPS: StartStep[] = [
{
step: "STEP 01",
title: "계정 연결",
description: "안내에 따라 거래 계정을 안전하게 연결합니다.",
},
{
step: "STEP 02",
title: "성향 선택",
description: "공격형·균형형·안정형 중 내 스타일을 고릅니다.",
},
{
step: "STEP 03",
title: "자동 실행 시작",
description: "선택한 전략으로 실시간 관제를 바로 시작합니다.",
},
];
/**
* 메인 페이지 컴포넌트 (비동기 서버 컴포넌트)
* @returns Landing Page Elements
* @see layout.tsx - RootLayout 내에서 렌더링
* @see spline-scene.tsx - 3D 인터랙션
* 메인 랜딩 페이지
* @returns 랜딩 UI
* @see features/layout/components/header.tsx blendWithBackground 모드 헤더를 함께 사용
*/
export default async function HomePage() {
// [Step 1] 서버 사이드 인증 상태 확인
// [로그인 상태 조회] 사용자 유무에 따라 CTA 링크를 분기합니다.
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
// [CTA 분기] 로그인 여부에 따라 같은 위치에서 다른 행동으로 자연스럽게 전환합니다.
const primaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP;
const primaryCtaLabel = user ? "대시보드로 전략 실행하기" : "무료로 시작하고 첫 전략 받기";
const secondaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.LOGIN;
const secondaryCtaLabel = 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-transparent">
<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 overflow-hidden pt-16">
{/* ========== SHADER BACKGROUND SECTION ========== */}
<ShaderBackground opacity={1} className="-z-20" />
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 bg-black/45" />
<div
aria-hidden="true"
className="pointer-events-none absolute -left-40 top-40 -z-10 h-96 w-96 rounded-full bg-brand-500/20 blur-3xl"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -right-24 top-72 -z-10 h-[26rem] w-[26rem] rounded-full bg-brand-300/20 blur-3xl"
/>
<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-7xl px-4 pb-14 pt-14 md:pt-24">
<div className="grid gap-8 lg:grid-cols-12 lg:gap-10">
<div className="lg:col-span-7">
<span className="inline-flex animate-in fade-in-0 items-center gap-2 rounded-full border border-brand-400/40 bg-brand-500/15 px-4 py-1.5 text-xs font-semibold tracking-wide text-brand-100 backdrop-blur-md duration-700">
<Sparkles className="h-3.5 w-3.5" />
</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-5 animate-in slide-in-from-bottom-4 text-4xl font-black tracking-tight text-white [text-shadow:0_6px_40px_rgba(0,0,0,0.55)] duration-700 md:text-6xl">
,
<br />
<span className="bg-linear-to-r from-brand-100 via-brand-300 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
.
<br className="hidden md:block" />
.
<p className="mt-5 max-w-2xl animate-in slide-in-from-bottom-4 text-sm leading-relaxed text-white/80 duration-700 md:text-lg">
.
<br />
, , .
</p>
<div className="mt-8 flex flex-col items-center gap-4 sm:flex-row">
{user ? (
<div className="mt-8 flex animate-in flex-col gap-3 duration-700 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-12 rounded-full bg-linear-to-r from-brand-500 via-brand-400 to-brand-600 px-8 text-base font-semibold text-white shadow-2xl shadow-brand-800/45 [background-size:200%_200%] animate-gradient-x hover:brightness-110"
>
<Link href={AUTH_ROUTES.DASHBOARD}> </Link>
<Link href={primaryCtaHref}>
{primaryCtaLabel}
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</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"
className="h-12 rounded-full border-white/35 bg-black/30 px-8 text-base text-white backdrop-blur-md hover:bg-white/10 hover:text-white"
>
<Link href={AUTH_ROUTES.LOGIN}> </Link>
<Link href={secondaryCtaHref}>{secondaryCtaLabel}</Link>
</Button>
)}
</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="mt-4 flex flex-wrap gap-3 text-xs text-white/70">
<span className="rounded-full border border-white/20 bg-white/5 px-3 py-1 backdrop-blur-sm">
3
</span>
<span className="rounded-full border border-white/20 bg-white/5 px-3 py-1 backdrop-blur-sm">
</span>
<span className="rounded-full border border-white/20 bg-white/5 px-3 py-1 backdrop-blur-sm">
</span>
</div>
</div>
<SplineScene
sceneUrl="https://prod.spline.design/6Wq1Q7YGyM-iab9i/scene.splinecode"
className="relative z-10 h-full w-full rounded-2xl"
/>
<div className="relative lg:col-span-5">
<div className="absolute -inset-px rounded-3xl bg-linear-to-br from-brand-300/50 via-brand-600/0 to-brand-600/60 blur-lg" />
<div className="relative overflow-hidden rounded-3xl border border-white/15 bg-black/35 p-6 shadow-[0_30px_90px_-45px_rgba(0,0,0,0.85)] backdrop-blur-2xl">
<div className="flex items-center justify-between border-b border-white/10 pb-4">
<div>
<p className="text-xs font-semibold tracking-wide text-brand-200">LIVE STRATEGY STATUS</p>
<p className="mt-1 text-lg font-semibold text-white"> </p>
</div>
<span className="inline-flex items-center gap-1 rounded-full bg-brand-500/25 px-3 py-1 text-xs font-semibold text-brand-100">
<Activity className="h-3.5 w-3.5" />
</span>
</div>
{/* [신뢰 포인트] UI 안에서 가치 제안을 한눈에 보여 주기 위해 핵심 상태를 카드형으로 배치합니다. */}
<div className="mt-5 grid gap-3">
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<p className="text-xs text-white/65"> </p>
<p className="mt-1 text-sm font-semibold text-white"> + </p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<p className="text-xs text-white/65"> </p>
<p className="mt-1 text-sm font-semibold text-white"> </p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<p className="text-xs text-white/65"> </p>
<p className="mt-1 text-sm font-semibold text-white"> , </p>
</div>
</div>
<div className="mt-5 flex items-center gap-2 text-xs text-brand-100/90">
<span className="inline-block h-2 w-2 rounded-full bg-brand-300" />
.
</div>
</div>
</div>
</div>
<div className="mt-10 grid gap-4 md:grid-cols-3">
{VALUE_POINTS.map((point) => (
<div
key={point.label}
className="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-md transition-colors hover:bg-white/10"
>
<p className="text-2xl font-black text-brand-100">{point.value}</p>
<p className="mt-2 text-sm font-semibold text-white">{point.label}</p>
<p className="mt-2 text-xs leading-relaxed text-white/70">{point.detail}</p>
</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>
</h2>
<p className="mt-4 text-lg text-muted-foreground">
.
</p>
</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">
.
{/* ========== FEATURE SECTION ========== */}
<section className="container mx-auto max-w-7xl px-4 pb-16 md:pb-24">
<div className="mx-auto max-w-3xl text-center">
<p className="text-xs font-semibold tracking-widest text-brand-200">WHY JURINI</p>
<h2 className="mt-3 text-3xl font-black tracking-tight text-white md:text-4xl">
<br />
.
</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" />
<span className="text-brand-200"> </span>
</h2>
</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"
<div className="mt-8 grid gap-6 md:grid-cols-3">
{FEATURE_ITEMS.map((feature) => {
const FeatureIcon = feature.icon;
return (
<Card
key={feature.title}
className="group border-white/10 bg-black/25 text-white shadow-none backdrop-blur-md transition-all hover:-translate-y-1 hover:border-brand-400/40 hover:bg-black/35"
>
<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>
<CardHeader>
<div className="mb-2 inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-500/20 text-brand-200 transition-transform group-hover:scale-110">
<FeatureIcon className="h-6 w-6" />
</div>
<h3 className="text-2xl font-bold"> </h3>
<p className="text-muted-foreground text-lg">
24 .
<p className="text-xs font-semibold tracking-wide text-brand-200">{feature.eyebrow}</p>
<CardTitle className="text-xl leading-snug text-white">{feature.title}</CardTitle>
</CardHeader>
<CardContent className="text-sm leading-relaxed text-white/75">
{feature.description}
</CardContent>
</Card>
);
})}
</div>
</section>
{/* ========== START STEP SECTION ========== */}
<section className="container mx-auto max-w-7xl px-4 pb-16">
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur-xl md:p-10">
<div className="flex flex-col justify-between gap-8 md:flex-row md:items-end">
<div>
<p className="text-xs font-semibold tracking-widest text-brand-200">GET STARTED</p>
<h2 className="mt-3 text-3xl font-black tracking-tight text-white md:text-4xl">
<br />
<span className="text-brand-200"> 3 </span>
</h2>
</div>
<p className="max-w-sm text-sm leading-relaxed text-white/70">
, .
</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 className="mt-8 grid gap-4 md:grid-cols-3">
{START_STEPS.map((item) => (
<div key={item.step} className="rounded-2xl border border-white/10 bg-black/30 p-5">
<p className="text-xs font-semibold tracking-wide text-brand-200">{item.step}</p>
<p className="mt-2 text-lg font-semibold text-white">{item.title}</p>
<p className="mt-2 text-sm leading-relaxed text-white/70">{item.description}</p>
</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>
</section>
{/* 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"
{/* ========== CTA SECTION ========== */}
<section className="container mx-auto max-w-7xl px-4 pb-20">
<div className="relative overflow-hidden rounded-3xl border border-brand-300/30 bg-linear-to-r from-brand-600/30 via-brand-500/20 to-brand-700/30 p-8 backdrop-blur-xl md:p-12">
<div
aria-hidden="true"
className="absolute -right-20 -top-20 h-72 w-72 rounded-full bg-brand-200/25 blur-3xl"
/>
</svg>
</div>
<div
aria-hidden="true"
className="absolute -bottom-24 -left-16 h-72 w-72 rounded-full bg-brand-700/30 blur-3xl"
/>
<div className="relative z-10 flex flex-col items-center justify-between gap-8 text-center md:flex-row md:text-left">
<div>
<h3 className="text-2xl font-bold mb-2"> </h3>
<p className="text-muted-foreground text-lg leading-relaxed">
, MDD를
<p className="text-sm font-semibold text-brand-100"> </p>
<h2 className="mt-2 text-3xl font-black tracking-tight text-white md:text-4xl">
,
<br />
.
</h2>
<p className="mt-3 text-sm text-white/75 md:text-base">
.
</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" />
<Button
asChild
size="lg"
className="group h-14 rounded-full bg-white px-9 text-lg font-bold text-brand-700 shadow-xl shadow-black/35 hover:bg-white/90"
>
<Link href={primaryCtaHref}>
<ArrowRight className="ml-2 h-5 w-5" />
</Link>
</Button>
</div>
</div>
</section>

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

View File

@@ -0,0 +1,45 @@
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");
const envAccountNo = process.env.KIS_ACCOUNT_NO;
const envAccountProductCode = process.env.KIS_ACCOUNT_PRODUCT_CODE;
return (
parseKisAccountParts(headerAccountNo, headerAccountProductCode) ??
parseKisAccountParts(envAccountNo, envAccountProductCode)
);
}

View File

@@ -0,0 +1,69 @@
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 {
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 credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
}
const account = readKisAccountParts(request.headers);
if (!account) {
return NextResponse.json(
{
error:
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
},
{ status: 400 },
);
}
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) {
const message =
error instanceof Error
? error.message
: "주문내역/매매일지 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,65 @@
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 {
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 credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
}
const account = readKisAccountParts(request.headers);
if (!account) {
return NextResponse.json(
{
error:
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
},
{ status: 400 },
);
}
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) {
const message =
error instanceof Error
? error.message
: "잔고 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,98 @@
import type {
DashboardChartTimeframe,
DashboardStockChartResponse,
} from "@/features/trade/types/trade.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticChart } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server";
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 { 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 NextResponse.json(
{ error: "symbol은 6자리 숫자여야 합니다." },
{ status: 400 },
);
}
if (!VALID_TIMEFRAMES.includes(timeframe)) {
return NextResponse.json(
{ error: "지원하지 않는 timeframe입니다." },
{ status: 400 },
);
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.",
},
{ status: 400 },
);
}
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) {
const message =
error instanceof Error
? error.message
: "KIS 차트 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
}
}
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,
};
}

View File

@@ -0,0 +1,50 @@
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 { 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 credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
}
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) {
const message =
error instanceof Error
? error.message
: "지수 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import { executeOrderCash } from "@/lib/kis/trade";
import {
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
} from "@/features/trade/types/trade.types";
import {
KisCredentialInput,
hasKisConfig,
normalizeTradingEnv,
} from "@/lib/kis/config";
/**
* @file app/api/kis/domestic/order-cash/route.ts
* @description 국내주식 현금 주문 API
*/
export async function POST(request: NextRequest) {
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
}
try {
const body = (await request.json()) as DashboardStockCashOrderRequest;
// TODO: Validate body fields (symbol, quantity, price, etc.)
if (
!body.symbol ||
!body.accountNo ||
!body.accountProductCode ||
body.quantity <= 0
) {
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message:
"주문 정보가 올바르지 않습니다. (종목코드, 계좌번호, 수량 확인)",
},
{ status: 400 },
);
}
const output = await executeOrderCash(
{
symbol: body.symbol,
side: body.side,
orderType: body.orderType,
quantity: body.quantity,
price: body.price,
accountNo: body.accountNo,
accountProductCode: body.accountProductCode,
},
credentials,
);
const response: DashboardStockCashOrderResponse = {
ok: true,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "주문이 전송되었습니다.",
orderNo: output.ODNO,
orderTime: output.ORD_TMD,
orderOrgNo: output.KRX_FWDG_ORD_ORGNO,
};
return NextResponse.json(response);
} catch (error) {
const message =
error instanceof Error
? error.message
: "주문 전송 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message,
},
{ status: 500 },
);
}
}
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,
};
}

View File

@@ -0,0 +1,165 @@
import { NextRequest, NextResponse } from "next/server";
import {
getDomesticOrderBook,
KisDomesticOrderBookOutput,
} from "@/lib/kis/domestic";
import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
import {
KisCredentialInput,
hasKisConfig,
normalizeTradingEnv,
} from "@/lib/kis/config";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
parseDomesticKisSession,
} from "@/lib/kis/domestic-market-session";
/**
* @file app/api/kis/domestic/orderbook/route.ts
* @description 국내주식 호가 조회 API
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json(
{ error: "symbol은 6자리 숫자여야 합니다." },
{ status: 400 },
);
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
}
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) {
const message =
error instanceof Error
? error.message
: "호가 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
}
}
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,
};
}
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,94 @@
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
import type { DashboardStockOverviewResponse } from "@/features/trade/types/trade.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticOverview } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
parseDomesticKisSession,
} from "@/lib/kis/domestic-market-session";
/**
* @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 { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json({ error: "symbol은 6자리 숫자여야 합니다." }, { status: 400 });
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 시세를 조회할 수 없습니다.",
},
{ status: 400 },
);
}
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) {
const message = error instanceof Error ? error.message : "KIS 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
}
}
/**
* 요청 헤더에서 KIS 키를 읽어옵니다.
* @param headers 요청 헤더
* @returns credentials
*/
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,
};
}
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,108 @@
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
import type {
DashboardStockSearchItem,
DashboardStockSearchResponse,
KoreanStockIndexItem,
} from "@/features/trade/types/trade.types";
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) {
// [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,58 @@
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 { 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 invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: invalidMessage,
} satisfies DashboardKisRevokeResponse,
{ status: 400 },
);
}
try {
const message = await revokeKisAccessToken(credentials);
return NextResponse.json({
ok: true,
tradingEnv,
message,
} satisfies DashboardKisRevokeResponse);
} catch (error) {
const message =
error instanceof Error
? error.message
: "API 토큰 폐기 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv,
message,
} satisfies DashboardKisRevokeResponse,
{ status: 401 },
);
}
}

View File

@@ -0,0 +1,235 @@
import { NextRequest, NextResponse } from "next/server";
import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types";
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";
interface KisProfileValidateRequestBody {
appKey?: string;
appSecret?: string;
tradingEnv?: string;
accountNo?: string;
}
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) {
let body: KisProfileValidateRequestBody = {};
try {
body = (await request.json()) as KisProfileValidateRequestBody;
} catch {
return NextResponse.json(
{
ok: false,
tradingEnv: "mock",
message: "요청 본문(JSON)을 읽을 수 없습니다.",
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
}
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 NextResponse.json(
{
ok: false,
tradingEnv,
message: invalidCredentialMessage,
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
}
const accountNoInput = (body.accountNo ?? "").trim();
if (!accountNoInput) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: "계좌번호를 입력해 주세요.",
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
}
const accountParts = parseKisAccountParts(accountNoInput);
if (!accountParts) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: "계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
}
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) {
const message =
error instanceof Error
? error.message
: "계좌 검증 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv,
message,
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
}
}
/**
* @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,58 @@
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 { 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 invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: invalidMessage,
} satisfies DashboardKisValidateResponse,
{ status: 400 },
);
}
try {
await getKisAccessToken(credentials);
return NextResponse.json({
ok: true,
tradingEnv,
message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)",
} satisfies DashboardKisValidateResponse);
} catch (error) {
const message =
error instanceof Error
? error.message
: "API 키 검증 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv,
message,
} satisfies DashboardKisValidateResponse,
{ status: 401 },
);
}
}

View File

@@ -0,0 +1,61 @@
import type { DashboardKisWsApprovalResponse } from "@/features/trade/types/trade.types";
import { getKisApprovalKey, resolveKisWebSocketUrl } from "@/lib/kis/approval";
import { normalizeTradingEnv } from "@/lib/kis/config";
import {
parseKisCredentialRequest,
validateKisCredentialInput,
} from "@/lib/kis/request";
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 invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: invalidMessage,
} satisfies DashboardKisWsApprovalResponse,
{ status: 400 },
);
}
try {
const approvalKey = await getKisApprovalKey(credentials);
const wsUrl = resolveKisWebSocketUrl(credentials);
return NextResponse.json({
ok: true,
tradingEnv,
approvalKey,
wsUrl,
message: "웹소켓 승인키 발급이 완료되었습니다.",
} satisfies DashboardKisWsApprovalResponse);
} catch (error) {
const message =
error instanceof Error
? error.message
: "웹소켓 승인키 발급 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv,
message,
} satisfies DashboardKisWsApprovalResponse,
{ status: 401 },
);
}
}

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,7 @@ 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 { Toaster } from "sonner";
import "./globals.css";
const geistSans = Geist({
@@ -32,8 +33,9 @@ const outfit = Outfit({
});
export const metadata: Metadata = {
title: "AutoTrade",
description: "Automated Crypto Trading Platform",
title: "Jurini - 감이 아닌 전략으로 시작하는 자동매매",
description:
"주린이를 위한 자동매매 파트너 Jurini. 손실 방어 규칙, 데이터 신호 분석, 실시간 자동 실행으로 초보의 첫 수익 루틴을 만듭니다.",
};
/**
@@ -61,6 +63,13 @@ export default function RootLayout({
>
<SessionManager />
<QueryProvider>{children}</QueryProvider>
<Toaster
richColors
position="top-right"
toastOptions={{
duration: 4000,
}}
/>
</ThemeProvider>
</body>
</html>

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

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,18 @@ 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);
}
}, []);
/**
* 로그아웃 처리 핸들러
* @see session-timer.tsx - 타임아웃 시 동일한 로직 필요 가능성 있음
@@ -64,11 +82,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 +98,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,115 @@
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
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),
cache: "no-store",
});
const payload = (await response.json()) as
| DashboardBalanceResponse
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "잔고 조회 중 오류가 발생했습니다.",
);
}
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
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "지수 조회 중 오류가 발생했습니다.",
);
}
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),
cache: "no-store",
});
const payload = (await response.json()) as
| DashboardActivityResponse
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "활동 데이터 조회 중 오류가 발생했습니다.",
);
}
return payload as DashboardActivityResponse;
}
/**
* 대시보드 API 공통 헤더를 구성합니다.
* @param credentials KIS 인증 정보
* @returns KIS 전달 헤더
* @see features/dashboard/apis/dashboard.api.ts fetchDashboardBalance/fetchDashboardIndices
*/
function buildKisRequestHeaders(credentials: KisRuntimeCredentials) {
const headers: Record<string, string> = {
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
};
if (credentials.accountNo?.trim()) {
headers["x-kis-account-no"] = credentials.accountNo.trim();
}
return headers;
}

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,228 @@
/**
* @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 {
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();
// [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={() =>
router.push(
`/trade?symbol=${holding.symbol}&name=${encodeURIComponent(holding.name)}`,
)
}
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,76 @@
"use client";
import { useEffect, useMemo, 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 { subscribe, connect, 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);
}
if (!isConnected) {
connect();
}
const unsubs: (() => void)[] = [];
uniqueSymbols.forEach((symbol) => {
const unsub = subscribe(STOCK_REALTIME_TR_ID, symbol, (data) => {
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, connect, subscribe, isConnected]);
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

@@ -0,0 +1,51 @@
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 { subscribe, connect, isConnected } = useKisWebSocketStore();
const callbackRef = useRef(onMessage);
// 콜백 함수가 바뀌어도 재구독하지 않도록 ref 사용
useEffect(() => {
callbackRef.current = onMessage;
}, [onMessage]);
useEffect(() => {
if (!enabled || !symbol || !trId) return;
// 연결 시도 (이미 연결 중이면 스토어에서 무시됨)
connect();
// 구독 요청
const unsubscribe = subscribe(trId, symbol, (data) => {
callbackRef.current?.(data);
});
// 언마운트 시 구독 해제
return () => {
unsubscribe();
};
}, [symbol, trId, enabled, connect, subscribe]);
return { isConnected };
}

View File

@@ -0,0 +1,386 @@
import { create } from "zustand";
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 pingInterval: number | undefined;
let isConnecting = false; // 연결 진행 중 상태 잠금
let reconnectRetryTimer: number | undefined;
let lastAppKeyConflictAt = 0;
export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
isConnected: false,
error: null,
connect: async (options) => {
const forceApprovalRefresh = options?.forceApprovalRefresh ?? false;
const currentSocket = socket;
if (currentSocket?.readyState === WebSocket.CLOSING) {
await waitForSocketClose(currentSocket);
}
// 1. 이미 연결되어 있거나, 연결 시도 중이면 중복 실행 방지
if (
socket?.readyState === WebSocket.OPEN ||
socket?.readyState === WebSocket.CONNECTING ||
socket?.readyState === WebSocket.CLOSING ||
isConnecting
) {
return;
}
try {
isConnecting = true;
const { getOrFetchWsConnection, clearWsConnectionCache } =
useKisRuntimeStore.getState();
if (forceApprovalRefresh) {
clearWsConnectionCache();
}
const wsConnection = await getOrFetchWsConnection();
// 비동기 대기 중에 다른 연결이 성사되었는지 다시 확인
if (
socket?.readyState === WebSocket.OPEN ||
socket?.readyState === WebSocket.CONNECTING
) {
isConnecting = false;
return;
}
if (!wsConnection) {
throw new Error("웹소켓 접속 키 발급에 실패했습니다.");
}
// 소켓 생성
// socket 변수에 할당하기 전에 로컬 변수로 제어하여 이벤트 핸들러 클로저 문제 방지
const ws = new WebSocket(`${wsConnection.wsUrl}/tryitout`);
socket = ws;
ws.onopen = () => {
isConnecting = false;
// socket 변수가 다른 인스턴스로 바뀌었을 가능성은 낮지만(락 때문),
// 안전을 위해 이벤트 발생 주체인 ws를 사용 또는 현재 socket 확인
if (socket !== ws) return;
set({ isConnected: true, error: null });
console.log("[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"); // 구독
}
});
}
// PINGPONG (Keep-alive)
window.clearInterval(pingInterval);
pingInterval = window.setInterval(() => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send("PINGPONG"); // 일부 환경에서는 PINGPONG 텍스트 전송
}
}, 100_000); // 100초 주기
};
ws.onclose = () => {
if (socket === ws) {
isConnecting = false;
set({ isConnected: false });
console.log("[KisWebSocket] Disconnected");
window.clearInterval(pingInterval);
socket = null;
}
};
ws.onerror = (event) => {
if (socket === ws) {
isConnecting = false;
console.error("[KisWebSocket] Error", event);
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?.rt_cd && control.rt_cd !== "0") {
const errorMessage = buildControlErrorMessage(control);
set({
error: errorMessage,
});
// KIS 제어 메시지: ALREADY IN USE appkey
// 이전 세션이 닫히기 전에 재연결될 때 간헐적으로 발생합니다.
if (control.msg_cd === "OPSP8996") {
const now = Date.now();
if (now - lastAppKeyConflictAt > 5_000) {
lastAppKeyConflictAt = now;
window.clearTimeout(reconnectRetryTimer);
reconnectRetryTimer = window.setTimeout(() => {
void get().reconnect({ refreshApproval: false });
}, 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];
// 데이터 부분 (마지막 부분)에서 종목코드를 찾아야 함.
// 하지만 응답에는 종목코드가 명시적으로 없는 경우가 많음 (순서로 추론).
// 다행히 KIS API는 요청했던 TR_ID와 수신된 데이터의 호가/체결 데이터를 매핑해야 함.
// 여기서는 모든 구독자에게 브로드캐스트하는 방식을 사용 (TR_ID 기준).
// 더 정확한 라우팅을 위해:
// 실시간 체결/호가 데이터에는 종목코드가 포함되어 있음.
// 체결(H0STCNT0): data.split("^")[0] (유가증권 단축종목코드)
const body = parts[3];
const values = body.split("^");
const symbol = values[0]; // 대부분 첫 번째 필드가 종목코드
const key = `${trId}|${symbol}`;
const callbacks = subscribers.get(key);
callbacks?.forEach((cb) => cb(data));
}
}
};
} catch (err) {
isConnecting = false;
set({
isConnected: false,
error: err instanceof Error ? err.message : "연결 실패",
});
}
},
reconnect: async (options) => {
const refreshApproval = options?.refreshApproval ?? false;
const currentSocket = socket;
get().disconnect();
if (currentSocket && currentSocket.readyState !== WebSocket.CLOSED) {
await waitForSocketClose(currentSocket);
}
await get().connect({
forceApprovalRefresh: refreshApproval,
});
},
disconnect: () => {
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.clearInterval(pingInterval);
window.clearTimeout(reconnectRetryTimer);
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);
// **연결이 안 되어 있으면 연결 시도**
if (!socket || socket.readyState !== WebSocket.OPEN) {
get().connect();
}
// 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));
console.debug(
`[KisWebSocket] ${trType === "1" ? "Sub" : "Unsub"} ${trId} ${symbol}`,
);
} catch (e) {
console.warn("[KisWebSocket] Send error", e);
}
}
interface KisWsControlMessage {
rt_cd?: string;
msg_cd?: string;
msg1?: 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 KisWsControlMessage;
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
/**
* @description KIS 웹소켓 제어 오류를 사용자용 짧은 문구로 변환합니다.
* @param message KIS 제어 메시지
* @returns 표시용 오류 문자열
* @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onmessage
*/
function buildControlErrorMessage(message: KisWsControlMessage) {
if (message.msg_cd === "OPSP8996") {
return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다.";
}
const detail = [message.msg1, message.msg_cd].filter(Boolean).join(" / ");
return detail ? `실시간 제어 메시지 오류: ${detail}` : "실시간 제어 메시지 오류";
}
/**
* @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);
});
}

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,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="Jurini 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,74 +9,129 @@ 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>
</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">
<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,97 @@
"use client";
import { cn } from "@/lib/utils";
import { BarChart2, Home, Settings, User, Wallet } from "lucide-react";
import {
BarChart2,
ChevronLeft,
Home,
Settings,
User,
Wallet,
} 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",
badge: "LIVE",
showInBottomNav: true,
},
{
title: "자산현황",
href: "/assets",
icon: Wallet,
variant: "ghost",
showInBottomNav: true,
},
{
title: "프로필",
href: "/profile",
icon: User,
variant: "ghost",
showInBottomNav: false,
},
{
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 +101,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 +155,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, User as UserIcon } 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,98 @@ 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);
}
};
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

@@ -6,4 +6,6 @@ export interface MenuItem {
icon: LucideIcon;
variant: "default" | "ghost";
matchExact?: boolean;
badge?: string;
showInBottomNav?: boolean;
}

View File

@@ -0,0 +1,97 @@
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import type {
DashboardKisProfileValidateResponse,
DashboardKisRevokeResponse,
DashboardKisValidateResponse,
DashboardKisWsApprovalResponse,
} from "@/features/trade/types/trade.types";
interface KisApiBaseResponse {
ok: boolean;
message: string;
}
async function postKisAuthApi<T extends KisApiBaseResponse>(
endpoint: string,
credentials: KisRuntimeCredentials,
fallbackErrorMessage: string,
): Promise<T> {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(credentials),
cache: "no-store",
});
const payload = (await response.json()) as T;
if (!response.ok || !payload.ok) {
throw new Error(payload.message || fallbackErrorMessage);
}
return payload;
}
/**
* @description KIS API 키를 검증합니다.
* @see app/api/kis/validate/route.ts
*/
export async function validateKisCredentials(
credentials: KisRuntimeCredentials,
): Promise<DashboardKisValidateResponse> {
return postKisAuthApi<DashboardKisValidateResponse>(
"/api/kis/validate",
credentials,
"앱키 검증에 실패했습니다.",
);
}
/**
* @description KIS 액세스 토큰을 폐기합니다.
* @see app/api/kis/revoke/route.ts
*/
export async function revokeKisCredentials(
credentials: KisRuntimeCredentials,
): Promise<DashboardKisRevokeResponse> {
return postKisAuthApi<DashboardKisRevokeResponse>(
"/api/kis/revoke",
credentials,
"API 토큰 폐기에 실패했습니다.",
);
}
/**
* @description 웹소켓 승인키와 WS URL을 조회합니다.
* @see app/api/kis/ws/approval/route.ts
*/
export async function fetchKisWebSocketApproval(
credentials: KisRuntimeCredentials,
): Promise<DashboardKisWsApprovalResponse> {
const payload = await postKisAuthApi<DashboardKisWsApprovalResponse>(
"/api/kis/ws/approval",
credentials,
"웹소켓 승인키 발급에 실패했습니다.",
);
if (!payload.approvalKey || !payload.wsUrl) {
throw new Error(payload.message || "웹소켓 연결 정보가 누락되었습니다.");
}
return payload;
}
/**
* @description 계좌번호를 검증합니다.
* @see app/api/kis/validate-profile/route.ts
*/
export async function validateKisProfile(
credentials: KisRuntimeCredentials,
): Promise<DashboardKisProfileValidateResponse> {
return postKisAuthApi<DashboardKisProfileValidateResponse>(
"/api/kis/validate-profile",
credentials,
"계좌 검증에 실패했습니다.",
);
}

View File

@@ -0,0 +1,310 @@
import { useState, useTransition } from "react";
import { useShallow } from "zustand/react/shallow";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import {
revokeKisCredentials,
validateKisCredentials,
} from "@/features/settings/apis/kis-auth.api";
import {
KeyRound,
ShieldCheck,
CheckCircle2,
XCircle,
Lock,
Link2,
Unlink2,
Activity,
Zap,
KeySquare,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { InlineSpinner } from "@/components/ui/loading-spinner";
import { SettingsCard } from "./SettingsCard";
/**
* @description 한국투자증권 앱키/앱시크릿키 인증 폼입니다.
* @remarks UI 흐름: /settings -> 앱키/앱시크릿키 입력 -> 연결 확인 버튼 -> /api/kis/validate -> 연결 상태 반영
* @see app/api/kis/validate/route.ts 앱키 검증 API
* @see features/settings/store/use-kis-runtime-store.ts 인증 상태 저장소
*/
export function KisAuthForm() {
const {
kisTradingEnvInput,
kisAppKeyInput,
kisAppSecretInput,
verifiedAccountNo,
verifiedCredentials,
isKisVerified,
setKisTradingEnvInput,
setKisAppKeyInput,
setKisAppSecretInput,
setVerifiedKisSession,
invalidateKisVerification,
clearKisRuntimeSession,
} = useKisRuntimeStore(
useShallow((state) => ({
kisTradingEnvInput: state.kisTradingEnvInput,
kisAppKeyInput: state.kisAppKeyInput,
kisAppSecretInput: state.kisAppSecretInput,
verifiedAccountNo: state.verifiedAccountNo,
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
setKisTradingEnvInput: state.setKisTradingEnvInput,
setKisAppKeyInput: state.setKisAppKeyInput,
setKisAppSecretInput: state.setKisAppSecretInput,
setVerifiedKisSession: state.setVerifiedKisSession,
invalidateKisVerification: state.invalidateKisVerification,
clearKisRuntimeSession: state.clearKisRuntimeSession,
})),
);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isValidating, startValidateTransition] = useTransition();
const [isRevoking, startRevokeTransition] = useTransition();
function handleValidate() {
startValidateTransition(async () => {
try {
setErrorMessage(null);
setStatusMessage(null);
const appKey = kisAppKeyInput.trim();
const appSecret = kisAppSecretInput.trim();
if (!appKey || !appSecret) {
throw new Error("앱키와 앱시크릿키를 모두 입력해 주세요.");
}
const credentials = {
appKey,
appSecret,
tradingEnv: kisTradingEnvInput,
accountNo: verifiedAccountNo ?? "",
};
const result = await validateKisCredentials(credentials);
setVerifiedKisSession(credentials, result.tradingEnv);
setStatusMessage(
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"})`,
);
} catch (err) {
invalidateKisVerification();
setErrorMessage(
err instanceof Error
? err.message
: "앱키 확인 중 오류가 발생했습니다.",
);
}
});
}
function handleRevoke() {
if (!verifiedCredentials) return;
startRevokeTransition(async () => {
try {
setErrorMessage(null);
setStatusMessage(null);
const result = await revokeKisCredentials(verifiedCredentials);
clearKisRuntimeSession(result.tradingEnv);
setStatusMessage(
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"})`,
);
} catch (err) {
setErrorMessage(
err instanceof Error
? err.message
: "연결 해제 중 오류가 발생했습니다.",
);
}
});
}
return (
<SettingsCard
icon={KeyRound}
title="한국투자증권 앱키 연결"
description="Open API에서 발급받은 앱키와 앱시크릿키를 입력해 연결을 완료하세요."
badge={
isKisVerified ? (
<span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-full bg-green-50 px-2 py-0.5 text-[11px] font-medium text-green-700 ring-1 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/30">
<CheckCircle2 className="h-3 w-3" />
</span>
) : undefined
}
footer={{
actions: (
<div className="flex flex-wrap gap-2">
<Button
onClick={handleValidate}
disabled={
isValidating ||
!kisAppKeyInput.trim() ||
!kisAppSecretInput.trim()
}
className="h-9 rounded-lg bg-brand-600 px-4 text-xs font-semibold text-white shadow-sm transition-all hover:bg-brand-700 hover:shadow disabled:opacity-50 disabled:shadow-none dark:bg-brand-600 dark:hover:bg-brand-500"
>
{isValidating ? (
<span className="flex items-center gap-1.5">
<InlineSpinner className="h-3 w-3 text-white" />
</span>
) : (
<span className="flex items-center gap-1.5">
<Link2 className="h-3.5 w-3.5 text-brand-100" />
</span>
)}
</Button>
{isKisVerified && (
<Button
variant="outline"
onClick={handleRevoke}
disabled={isRevoking}
className="h-9 rounded-lg border-zinc-200 bg-white px-4 text-xs text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
>
{isRevoking ? (
"해제 중"
) : (
<span className="flex items-center gap-1.5">
<Unlink2 className="h-3.5 w-3.5" />
</span>
)}
</Button>
)}
</div>
),
status: (
<div className="flex min-h-5 items-center justify-start sm:justify-end">
{errorMessage && (
<p className="animate-in fade-in slide-in-from-right-4 flex items-center gap-1.5 text-xs font-semibold text-red-500">
<XCircle className="h-3.5 w-3.5" />
{errorMessage}
</p>
)}
{statusMessage && (
<p className="animate-in fade-in slide-in-from-right-4 flex items-center gap-1.5 text-xs font-semibold text-brand-600 dark:text-brand-400">
<CheckCircle2 className="h-3.5 w-3.5" />
{statusMessage}
</p>
)}
{!errorMessage && !statusMessage && !isKisVerified && (
<p className="flex items-center gap-1.5 text-xs text-zinc-400 dark:text-zinc-600">
<span className="h-1.5 w-1.5 rounded-full bg-zinc-300 dark:bg-zinc-700" />
</p>
)}
</div>
),
}}
className="h-full"
>
<div className="space-y-4">
{/* ========== TRADING MODE ========== */}
<section className="rounded-xl border border-zinc-200 bg-zinc-50/70 p-3 dark:border-zinc-800 dark:bg-zinc-900/30">
<div className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-zinc-700 dark:text-zinc-200">
<ShieldCheck className="h-3.5 w-3.5 text-brand-500" />
</div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setKisTradingEnvInput("real")}
className={cn(
"flex h-9 items-center justify-center gap-1.5 rounded-lg border text-xs font-semibold transition",
kisTradingEnvInput === "real"
? "border-brand-500 bg-brand-600 text-white shadow-sm"
: "border-zinc-200 bg-white text-zinc-600 hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300",
)}
>
<Zap className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => setKisTradingEnvInput("mock")}
className={cn(
"flex h-9 items-center justify-center gap-1.5 rounded-lg border text-xs font-semibold transition",
kisTradingEnvInput === "mock"
? "border-zinc-700 bg-zinc-800 text-white shadow-sm dark:border-zinc-500 dark:bg-zinc-700"
: "border-zinc-200 bg-white text-zinc-600 hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300",
)}
>
<Activity className="h-3.5 w-3.5" />
</button>
</div>
</section>
{/* ========== APP KEY INPUTS ========== */}
<div className="space-y-3">
<CredentialInput
id="kis-app-key"
label="앱키"
placeholder="한국투자증권 앱키 입력"
value={kisAppKeyInput}
onChange={setKisAppKeyInput}
icon={KeySquare}
/>
<CredentialInput
id="kis-app-secret"
label="앱시크릿키"
placeholder="한국투자증권 앱시크릿키 입력"
value={kisAppSecretInput}
onChange={setKisAppSecretInput}
icon={Lock}
/>
</div>
</div>
</SettingsCard>
);
}
/**
* @description 앱키/시크릿키 입력 전용 필드 블록입니다.
* @see features/settings/components/KisAuthForm.tsx 입력 UI 렌더링
*/
function CredentialInput({
id,
label,
value,
placeholder,
onChange,
icon: Icon,
}: {
id: string;
label: string;
value: string;
placeholder: string;
onChange: (value: string) => void;
icon: LucideIcon;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id} className="text-xs font-semibold text-zinc-600">
{label}
</Label>
<div className="group/input flex items-center overflow-hidden rounded-xl border border-zinc-200 bg-white transition-colors focus-within:border-brand-500 focus-within:ring-1 focus-within:ring-brand-500 dark:border-zinc-700 dark:bg-zinc-900/20">
<div className="flex h-10 w-10 shrink-0 items-center justify-center border-r border-zinc-100 bg-zinc-50 text-zinc-400 group-focus-within/input:text-brand-500 dark:border-zinc-800 dark:bg-zinc-800/40">
<Icon className="h-4 w-4" />
</div>
<Input
id={id}
type="password"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="h-10 border-none bg-transparent px-3 text-sm shadow-none focus-visible:ring-0"
autoComplete="off"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,251 @@
"use client";
import { useState, useTransition } from "react";
import { useShallow } from "zustand/react/shallow";
import {
CreditCard,
CheckCircle2,
SearchCheck,
ShieldOff,
XCircle,
FileLock2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { InlineSpinner } from "@/components/ui/loading-spinner";
import { validateKisProfile } from "@/features/settings/apis/kis-auth.api";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import { SettingsCard } from "./SettingsCard";
/**
* @description 한국투자증권 계좌번호 검증 폼입니다.
* @remarks UI 흐름: /settings -> 계좌번호 입력 -> 계좌 확인 버튼 -> validate-profile API -> store 반영 -> 대시보드 반영
* @see app/api/kis/validate-profile/route.ts 계좌번호 검증 서버 라우트
* @see features/settings/store/use-kis-runtime-store.ts 검증 성공값을 전역 상태에 저장합니다.
*/
export function KisProfileForm() {
const {
kisAccountNoInput,
verifiedCredentials,
isKisVerified,
isKisProfileVerified,
verifiedAccountNo,
setKisAccountNoInput,
setVerifiedKisProfile,
invalidateKisProfileVerification,
} = useKisRuntimeStore(
useShallow((state) => ({
kisAccountNoInput: state.kisAccountNoInput,
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
isKisProfileVerified: state.isKisProfileVerified,
verifiedAccountNo: state.verifiedAccountNo,
setKisAccountNoInput: state.setKisAccountNoInput,
setVerifiedKisProfile: state.setVerifiedKisProfile,
invalidateKisProfileVerification: state.invalidateKisProfileVerification,
})),
);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isValidating, startValidateTransition] = useTransition();
/**
* @description 계좌번호 인증을 해제하고 입력값을 비웁니다.
* @see features/settings/store/use-kis-runtime-store.ts setKisAccountNoInput
* @see features/settings/store/use-kis-runtime-store.ts invalidateKisProfileVerification
*/
function handleDisconnectAccount() {
setStatusMessage("계좌 인증을 해제했습니다.");
setErrorMessage(null);
setKisAccountNoInput("");
invalidateKisProfileVerification();
}
function handleValidateProfile() {
startValidateTransition(async () => {
try {
setStatusMessage(null);
setErrorMessage(null);
if (!verifiedCredentials || !isKisVerified) {
throw new Error("먼저 앱키 연결을 완료해 주세요.");
}
const accountNo = kisAccountNoInput.trim();
if (!accountNo) {
throw new Error("계좌번호를 입력해 주세요.");
}
if (!isValidAccountNo(accountNo)) {
throw new Error(
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
);
}
const result = await validateKisProfile({
...verifiedCredentials,
accountNo,
});
setVerifiedKisProfile({
accountNo: result.account.normalizedAccountNo,
});
setStatusMessage(result.message);
} catch (error) {
invalidateKisProfileVerification();
setErrorMessage(
error instanceof Error
? error.message
: "계좌 확인 중 오류가 발생했습니다.",
);
}
});
}
return (
<SettingsCard
icon={CreditCard}
title="한국투자증권 계좌 인증"
description="앱키 연결 후 계좌번호를 검증하면 잔고/대시보드 기능을 사용할 수 있습니다."
badge={
isKisProfileVerified ? (
<span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-full bg-green-50 px-2 py-0.5 text-[11px] font-medium text-green-700 ring-1 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/30">
<CheckCircle2 className="h-3 w-3" />
</span>
) : undefined
}
className={
!isKisVerified ? "opacity-60 grayscale pointer-events-none" : undefined
}
footer={{
actions: (
<div className="flex flex-wrap gap-2">
<Button
type="button"
onClick={handleValidateProfile}
disabled={
!isKisVerified || isValidating || !kisAccountNoInput.trim()
}
className="h-9 rounded-lg bg-brand-600 px-4 text-xs font-semibold text-white shadow-sm transition-all hover:bg-brand-700 hover:shadow disabled:opacity-50 disabled:shadow-none dark:bg-brand-600 dark:hover:bg-brand-500"
>
{isValidating ? (
<span className="flex items-center gap-1.5">
<InlineSpinner className="h-3 w-3 text-white" />
</span>
) : (
<span className="flex items-center gap-1.5">
<SearchCheck className="h-3.5 w-3.5" />
</span>
)}
</Button>
<Button
type="button"
variant="outline"
onClick={handleDisconnectAccount}
disabled={!isKisProfileVerified && !kisAccountNoInput.trim()}
className="h-9 rounded-lg border-zinc-200 bg-white px-4 text-xs text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
>
<ShieldOff className="h-3.5 w-3.5" />
</Button>
</div>
),
status: (
<div className="flex min-h-5 items-center justify-start sm:justify-end">
{errorMessage && (
<p className="animate-in fade-in slide-in-from-right-4 flex items-center gap-1.5 text-xs font-semibold text-red-500">
<XCircle className="h-3.5 w-3.5" />
{errorMessage}
</p>
)}
{statusMessage && (
<p className="animate-in fade-in slide-in-from-right-4 flex items-center gap-1.5 text-xs font-semibold text-brand-600 dark:text-brand-400">
<CheckCircle2 className="h-3.5 w-3.5" />
{statusMessage}
</p>
)}
{!statusMessage && !errorMessage && !isKisVerified && (
<p className="flex items-center gap-1.5 text-xs text-zinc-400 dark:text-zinc-600">
<span className="h-1.5 w-1.5 rounded-full bg-zinc-300 dark:bg-zinc-700" />
</p>
)}
{!statusMessage && !errorMessage && isKisProfileVerified && (
<p className="animate-in fade-in slide-in-from-right-4 flex items-center gap-1.5 text-xs font-medium text-zinc-500 dark:text-zinc-400">
: {maskAccountNo(verifiedAccountNo)}
</p>
)}
</div>
),
}}
>
<div className="space-y-4">
{/* ========== ACCOUNT GUIDE ========== */}
<section className="rounded-xl border border-zinc-200 bg-zinc-50/70 p-3 dark:border-zinc-800 dark:bg-zinc-900/30">
<p className="flex items-center gap-1.5 text-xs font-semibold text-zinc-700 dark:text-zinc-200">
<FileLock2 className="h-3.5 w-3.5 text-brand-500" />
</p>
<p className="mt-1 text-xs leading-relaxed text-zinc-600 dark:text-zinc-300">
8-2 . : <span className="font-medium">12345678-01</span>
</p>
</section>
{/* ========== ACCOUNT NO INPUT ========== */}
<div className="space-y-1.5">
<Label
htmlFor="kis-account-no"
className="text-xs font-semibold text-zinc-600"
>
</Label>
<div className="group/input flex items-center overflow-hidden rounded-xl border border-zinc-200 bg-white transition-colors focus-within:border-brand-500 focus-within:ring-1 focus-within:ring-brand-500 dark:border-zinc-700 dark:bg-zinc-900/20">
<div className="flex h-10 w-10 shrink-0 items-center justify-center border-r border-zinc-100 bg-zinc-50 text-zinc-400 group-focus-within/input:text-brand-500 dark:border-zinc-800 dark:bg-zinc-800/40">
<CreditCard className="h-4 w-4" />
</div>
<Input
id="kis-account-no"
type="password"
value={kisAccountNoInput}
onChange={(e) => setKisAccountNoInput(e.target.value)}
placeholder="계좌번호 (예: 12345678-01)"
className="h-10 border-none bg-transparent px-3 text-sm shadow-none focus-visible:ring-0"
autoComplete="off"
/>
</div>
</div>
</div>
</SettingsCard>
);
}
/**
* @description KIS 계좌번호(8-2) 입력 포맷을 검증합니다.
* @param value 사용자 입력 계좌번호
* @returns 형식 유효 여부
* @see features/settings/components/KisProfileForm.tsx handleValidateProfile
*/
function isValidAccountNo(value: string) {
const digits = value.replace(/\D/g, "");
return digits.length === 10;
}
/**
* @description 표시용 계좌번호를 마스킹 처리합니다.
* @param value 계좌번호(8-2)
* @returns 마스킹 계좌번호
* @see features/settings/components/KisProfileForm.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,98 @@
"use client";
import { ReactNode } from "react";
import { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface SettingsCardProps {
/** 카드 상단에 표시될 아이콘 컴포넌트 */
icon: LucideIcon;
/** 카드 제목 */
title: ReactNode;
/** 제목 옆에 표시될 배지 (선택 사항) */
badge?: ReactNode;
/** 헤더 우측에 표시될 액션 요소 (스위치, 버튼 등) */
headerAction?: ReactNode;
/** 카드 설명 텍스트 */
description?: string;
/** 카드 본문 컨텐츠 */
children: ReactNode;
/** 카드 하단 영역 (액션 버튼 및 상태 메시지 포함) */
footer?: {
/** 좌측 액션 버튼들 */
actions?: ReactNode;
/** 우측 상태 메시지 */
status?: ReactNode;
};
/** 추가 클래스 */
className?: string;
}
/**
* @description 설정 페이지에서 사용되는 통일된 카드 UI 컴포넌트입니다.
* @remarks 모든 설정 폼(인증, 프로필 등)은 이 컴포넌트를 사용하여 일관된 디자인을 유지해야 합니다.
*/
export function SettingsCard({
icon: Icon,
title,
badge,
headerAction,
description,
children,
footer,
className,
}: SettingsCardProps) {
return (
<div
className={cn(
"group relative flex h-full flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900/50 dark:hover:border-brand-800 dark:hover:shadow-brand-900/10",
className,
)}
>
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-brand-300/70 to-transparent dark:via-brand-600/60" />
<div className="flex flex-1 flex-col p-5 sm:p-6">
{/* ========== CARD HEADER ========== */}
<div className="mb-5 flex flex-col gap-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-50 text-brand-600 ring-1 ring-brand-100 dark:bg-brand-900/20 dark:text-brand-400 dark:ring-brand-800/50">
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="truncate text-base font-bold tracking-tight text-zinc-900 dark:text-zinc-100">
{title}
</h2>
{badge && <div className="shrink-0">{badge}</div>}
</div>
{description && (
<p className="mt-1 text-[13px] font-medium leading-normal text-zinc-500 dark:text-zinc-400">
{description}
</p>
)}
</div>
</div>
{headerAction && (
<div className="sm:shrink-0 sm:pl-2">{headerAction}</div>
)}
</div>
</div>
{/* ========== CARD BODY ========== */}
<div className="flex-1">{children}</div>
</div>
{/* ========== CARD FOOTER ========== */}
{footer && (
<div className="border-t border-zinc-100 bg-zinc-50/50 px-5 py-3 dark:border-zinc-800/50 dark:bg-zinc-900/30 sm:px-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap gap-2">{footer.actions}</div>
<div className="text-left sm:text-right">{footer.status}</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,141 @@
"use client";
import { type LucideIcon, Info, Link2, Wallet } from "lucide-react";
import { useShallow } from "zustand/react/shallow";
import { KisAuthForm } from "@/features/settings/components/KisAuthForm";
import { KisProfileForm } from "@/features/settings/components/KisProfileForm";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import { cn } from "@/lib/utils";
/**
* @description 설정 페이지 컨테이너입니다. KIS 연결 상태와 인증 폼을 카드 UI로 제공합니다.
* @see app/(main)/settings/page.tsx 로그인 확인 후 이 컴포넌트를 렌더링합니다.
* @see features/settings/components/KisAuthForm.tsx 실제 인증 입력/검증/해제를 담당합니다.
*/
export function SettingsContainer() {
// 상태 정의: 연결 상태 표시용 전역 인증 상태를 구독합니다.
const {
verifiedCredentials,
isKisVerified,
isKisProfileVerified,
verifiedAccountNo,
} = useKisRuntimeStore(
useShallow((state) => ({
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
isKisProfileVerified: state.isKisProfileVerified,
verifiedAccountNo: state.verifiedAccountNo,
})),
);
return (
<section className="mx-auto flex w-full max-w-[1400px] flex-col gap-6 px-4 py-4 md:px-8 md:py-8">
{/* ========== SETTINGS OVERVIEW ========== */}
<article className="rounded-2xl border border-brand-200 bg-gradient-to-br from-brand-50/80 via-background to-background p-5 dark:border-brand-800/45 dark:from-brand-900/30 dark:via-brand-950/10 dark:to-background md:p-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]">
<div className="space-y-3">
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
</h1>
<p className="text-sm leading-relaxed text-muted-foreground">
.
/ .
</p>
<div className="rounded-xl border border-brand-200/70 bg-brand-50/70 p-3 dark:border-brand-800/60 dark:bg-brand-900/20">
<p className="text-xs font-semibold text-brand-700 dark:text-brand-200">
순서: 1) {"->"} 2) {"->"} 3)
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
<StatusTile
icon={Link2}
title="앱키 연결"
value={
isKisVerified
? `연결됨 (${verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"})`
: "미연결"
}
tone={isKisVerified ? "success" : "idle"}
/>
<StatusTile
icon={Wallet}
title="계좌 인증"
value={
isKisProfileVerified
? `확인 완료 (${maskAccountNo(verifiedAccountNo)})`
: "미확인"
}
tone={isKisProfileVerified ? "success" : "idle"}
/>
<StatusTile
icon={Info}
title="입력 정보 보관"
value="서버 DB 저장 없음 · 현재 브라우저에서만 관리"
tone="notice"
/>
</div>
</div>
</article>
{/* ========== FORM GRID ========== */}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)]">
<KisAuthForm />
<KisProfileForm />
</div>
</section>
);
}
/**
* @description 계좌번호 마스킹 문자열을 반환합니다.
* @param value 계좌번호(8-2)
* @returns 마스킹 계좌번호
* @see features/settings/components/SettingsContainer.tsx 프로필 상태 라벨 표시
*/
function maskAccountNo(value: string | null) {
if (!value) return "-";
const digits = value.replace(/\D/g, "");
if (digits.length !== 10) return "********";
return "********-**";
}
type StatusTileTone = "success" | "idle" | "notice";
/**
* @description 설정 페이지 상단 요약 상태 타일입니다.
* @see features/settings/components/SettingsContainer.tsx 상태 요약 렌더링
*/
function StatusTile({
icon: Icon,
title,
value,
tone,
}: {
icon: LucideIcon;
title: string;
value: string;
tone: StatusTileTone;
}) {
return (
<div
className={cn(
"rounded-xl border px-3 py-2.5",
tone === "success" &&
"border-emerald-200 bg-emerald-50/70 dark:border-emerald-800/45 dark:bg-emerald-900/15",
tone === "idle" &&
"border-zinc-200 bg-zinc-50/70 dark:border-zinc-800 dark:bg-zinc-900/30",
tone === "notice" &&
"border-amber-300 bg-amber-50/70 dark:border-amber-800/45 dark:bg-amber-900/20",
)}
>
<p className="flex items-center gap-1.5 text-[12px] font-semibold text-zinc-700 dark:text-zinc-200">
<Icon className="h-3.5 w-3.5" />
{title}
</p>
<p className="mt-1 text-xs text-zinc-600 dark:text-zinc-300">{value}</p>
</div>
);
}

View File

@@ -0,0 +1,261 @@
"use client";
import { fetchKisWebSocketApproval } from "@/features/settings/apis/kis-auth.api";
import type { KisTradingEnv } from "@/features/trade/types/trade.types";
import { createJSONStorage, persist } from "zustand/middleware";
import { create } from "zustand";
/**
* @file features/settings/store/use-kis-runtime-store.ts
* @description Stores KIS input, verification, and websocket connection state.
* @see features/trade/hooks/useKisTradeWebSocket.ts
*/
export interface KisRuntimeCredentials {
appKey: string;
appSecret: string;
tradingEnv: KisTradingEnv;
accountNo: string;
}
interface KisWsConnection {
approvalKey: string;
wsUrl: string;
}
interface KisRuntimeStoreState {
kisTradingEnvInput: KisTradingEnv;
kisAppKeyInput: string;
kisAppSecretInput: string;
kisAccountNoInput: string;
verifiedCredentials: KisRuntimeCredentials | null;
isKisVerified: boolean;
isKisProfileVerified: boolean;
verifiedAccountNo: string | null;
tradingEnv: KisTradingEnv;
wsApprovalKey: string | null;
wsUrl: string | null;
_hasHydrated: boolean;
}
interface KisRuntimeStoreActions {
setKisTradingEnvInput: (tradingEnv: KisTradingEnv) => void;
setKisAppKeyInput: (appKey: string) => void;
setKisAppSecretInput: (appSecret: string) => void;
setKisAccountNoInput: (accountNo: string) => void;
setVerifiedKisSession: (
credentials: KisRuntimeCredentials,
tradingEnv: KisTradingEnv,
) => void;
setVerifiedKisProfile: (profile: {
accountNo: string;
}) => void;
invalidateKisProfileVerification: () => void;
invalidateKisVerification: () => void;
clearKisRuntimeSession: (tradingEnv: KisTradingEnv) => void;
getOrFetchWsConnection: () => Promise<KisWsConnection | null>;
clearWsConnectionCache: () => void;
setHasHydrated: (state: boolean) => void;
}
const INITIAL_STATE: KisRuntimeStoreState = {
kisTradingEnvInput: "real",
kisAppKeyInput: "",
kisAppSecretInput: "",
kisAccountNoInput: "",
verifiedCredentials: null,
isKisVerified: false,
isKisProfileVerified: false,
verifiedAccountNo: null,
tradingEnv: "real",
wsApprovalKey: null,
wsUrl: null,
_hasHydrated: false,
};
const RESET_PROFILE_STATE = {
isKisProfileVerified: false,
verifiedAccountNo: null,
} as const;
const RESET_VERIFICATION_STATE = {
verifiedCredentials: null,
isKisVerified: false,
...RESET_PROFILE_STATE,
wsApprovalKey: null,
wsUrl: null,
};
let wsConnectionPromise: Promise<KisWsConnection | null> | null = null;
/**
* @description Runtime store for KIS session.
* @see features/settings/components/KisAuthForm.tsx
*/
export const useKisRuntimeStore = create<
KisRuntimeStoreState & KisRuntimeStoreActions
>()(
persist(
(set, get) => ({
...INITIAL_STATE,
setKisTradingEnvInput: (tradingEnv) =>
set({
kisTradingEnvInput: tradingEnv,
...RESET_VERIFICATION_STATE,
}),
setKisAppKeyInput: (appKey) =>
set({
kisAppKeyInput: appKey,
...RESET_VERIFICATION_STATE,
}),
setKisAppSecretInput: (appSecret) =>
set({
kisAppSecretInput: appSecret,
...RESET_VERIFICATION_STATE,
}),
setKisAccountNoInput: (accountNo) =>
set((state) => ({
kisAccountNoInput: accountNo,
...RESET_PROFILE_STATE,
verifiedCredentials: state.verifiedCredentials
? {
...state.verifiedCredentials,
accountNo: "",
}
: null,
})),
setVerifiedKisSession: (credentials, tradingEnv) =>
set({
verifiedCredentials: credentials,
isKisVerified: true,
tradingEnv,
wsApprovalKey: null,
wsUrl: null,
}),
setVerifiedKisProfile: ({ accountNo }) =>
set((state) => ({
isKisProfileVerified: true,
verifiedAccountNo: accountNo,
verifiedCredentials: state.verifiedCredentials
? {
...state.verifiedCredentials,
accountNo,
}
: state.verifiedCredentials,
wsApprovalKey: null,
wsUrl: null,
})),
invalidateKisProfileVerification: () =>
set((state) => ({
...RESET_PROFILE_STATE,
verifiedCredentials: state.verifiedCredentials
? {
...state.verifiedCredentials,
accountNo: "",
}
: state.verifiedCredentials,
wsApprovalKey: null,
wsUrl: null,
})),
invalidateKisVerification: () =>
set({
...RESET_VERIFICATION_STATE,
}),
clearKisRuntimeSession: (tradingEnv) =>
set({
kisTradingEnvInput: tradingEnv,
kisAppKeyInput: "",
kisAppSecretInput: "",
kisAccountNoInput: "",
...RESET_VERIFICATION_STATE,
tradingEnv,
}),
getOrFetchWsConnection: async () => {
const { wsApprovalKey, wsUrl, verifiedCredentials } = get();
if (wsApprovalKey && wsUrl) {
return { approvalKey: wsApprovalKey, wsUrl };
}
if (!verifiedCredentials) {
return null;
}
if (wsConnectionPromise) {
return wsConnectionPromise;
}
wsConnectionPromise = (async () => {
try {
const data = await fetchKisWebSocketApproval(verifiedCredentials);
if (!data.approvalKey || !data.wsUrl) {
return null;
}
const nextConnection = {
approvalKey: data.approvalKey,
wsUrl: data.wsUrl,
} satisfies KisWsConnection;
set({
wsApprovalKey: nextConnection.approvalKey,
wsUrl: nextConnection.wsUrl,
});
return nextConnection;
} catch (error) {
console.error(error);
return null;
} finally {
wsConnectionPromise = null;
}
})();
return wsConnectionPromise;
},
clearWsConnectionCache: () => {
wsConnectionPromise = null;
set({
wsApprovalKey: null,
wsUrl: null,
});
},
setHasHydrated: (state) => {
set({
_hasHydrated: state,
});
},
}),
{
name: "autotrade-kis-runtime-store",
storage: createJSONStorage(() => localStorage),
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true);
},
partialize: (state) => ({
kisTradingEnvInput: state.kisTradingEnvInput,
kisAppKeyInput: state.kisAppKeyInput,
kisAppSecretInput: state.kisAppSecretInput,
kisAccountNoInput: state.kisAccountNoInput,
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
isKisProfileVerified: state.isKisProfileVerified,
verifiedAccountNo: state.verifiedAccountNo,
tradingEnv: state.tradingEnv,
// wsApprovalKey/wsUrl are kept in memory only (expiration-sensitive).
}),
},
),
);

View File

@@ -0,0 +1,202 @@
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import type {
DashboardChartTimeframe,
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
DashboardStockChartResponse,
DashboardStockOrderBookResponse,
DashboardStockOverviewResponse,
DashboardStockSearchResponse,
} from "@/features/trade/types/trade.types";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
parseDomesticKisSession,
} from "@/lib/kis/domestic-market-session";
/**
* 종목 검색 API 호출
* @param keyword 검색어
*/
export async function fetchStockSearch(
keyword: string,
signal?: AbortSignal,
): Promise<DashboardStockSearchResponse> {
const response = await fetch(
`/api/kis/domestic/search?q=${encodeURIComponent(keyword)}`,
{
cache: "no-store",
signal,
},
);
const payload = (await response.json()) as
| DashboardStockSearchResponse
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "종목 검색 중 오류가 발생했습니다.",
);
}
return payload as DashboardStockSearchResponse;
}
/**
* 종목 상세 개요 조회 API 호출
* @param symbol 종목코드
* @param credentials KIS 인증 정보
*/
export async function fetchStockOverview(
symbol: string,
credentials: KisRuntimeCredentials,
): Promise<DashboardStockOverviewResponse> {
const response = await fetch(
`/api/kis/domestic/overview?symbol=${encodeURIComponent(symbol)}`,
{
method: "GET",
headers: buildKisRequestHeaders(credentials),
cache: "no-store",
},
);
const payload = (await response.json()) as
| DashboardStockOverviewResponse
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "종목 조회 중 오류가 발생했습니다.",
);
}
return payload as DashboardStockOverviewResponse;
}
/**
* 종목 호가 조회 API 호출
* @param symbol 종목코드
* @param credentials KIS 인증 정보
*/
export async function fetchStockOrderBook(
symbol: string,
credentials: KisRuntimeCredentials,
signal?: AbortSignal,
): Promise<DashboardStockOrderBookResponse> {
const response = await fetch(
`/api/kis/domestic/orderbook?symbol=${encodeURIComponent(symbol)}`,
{
method: "GET",
headers: buildKisRequestHeaders(credentials),
cache: "no-store",
signal,
},
);
const payload = (await response.json()) as
| DashboardStockOrderBookResponse
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "호가 조회 중 오류가 발생했습니다.",
);
}
return payload as DashboardStockOrderBookResponse;
}
/**
* 종목 차트(분봉/일봉/주봉) 조회 API 호출
*/
export async function fetchStockChart(
symbol: string,
timeframe: DashboardChartTimeframe,
credentials: KisRuntimeCredentials,
cursor?: string,
): Promise<DashboardStockChartResponse> {
const query = new URLSearchParams({
symbol,
timeframe,
});
if (cursor) query.set("cursor", cursor);
const response = await fetch(`/api/kis/domestic/chart?${query.toString()}`, {
method: "GET",
headers: buildKisRequestHeaders(credentials),
cache: "no-store",
});
const payload = (await response.json()) as
| DashboardStockChartResponse
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "차트 조회 중 오류가 발생했습니다.",
);
}
return payload as DashboardStockChartResponse;
}
/**
* 주식 현금 주문 API 호출
* @param request 주문 요청 데이터
* @param credentials KIS 인증 정보
*/
export async function fetchOrderCash(
request: DashboardStockCashOrderRequest,
credentials: KisRuntimeCredentials,
): Promise<DashboardStockCashOrderResponse> {
const response = await fetch("/api/kis/domestic/order-cash", {
method: "POST",
headers: buildKisRequestHeaders(credentials, { jsonContentType: true }),
body: JSON.stringify(request),
cache: "no-store",
});
const payload = (await response.json()) as DashboardStockCashOrderResponse;
if (!response.ok) {
throw new Error(payload.message || "주문 전송 중 오류가 발생했습니다.");
}
return payload;
}
function buildKisRequestHeaders(
credentials: KisRuntimeCredentials,
options?: { jsonContentType?: boolean },
) {
const headers: Record<string, string> = {
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
};
if (options?.jsonContentType) {
headers["content-type"] = "application/json";
}
const sessionOverride = readSessionOverrideForDev();
if (sessionOverride) {
headers[DOMESTIC_KIS_SESSION_OVERRIDE_HEADER] = sessionOverride;
}
return headers;
}
function readSessionOverrideForDev() {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
);
return parseDomesticKisSession(raw);
} catch {
return null;
}
}

View File

@@ -0,0 +1,267 @@
"use client";
import { type FormEvent, useCallback, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useShallow } from "zustand/react/shallow";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import { TradeAccessGate } from "@/features/trade/components/guards/TradeAccessGate";
import { TradeDashboardContent } from "@/features/trade/components/layout/TradeDashboardContent";
import { TradeSearchSection } from "@/features/trade/components/search/TradeSearchSection";
import { useStockSearch } from "@/features/trade/hooks/useStockSearch";
import { useOrderBook } from "@/features/trade/hooks/useOrderBook";
import { useKisTradeWebSocket } from "@/features/trade/hooks/useKisTradeWebSocket";
import { useStockOverview } from "@/features/trade/hooks/useStockOverview";
import { useCurrentPrice } from "@/features/trade/hooks/useCurrentPrice";
import { useTradeSearchPanel } from "@/features/trade/hooks/useTradeSearchPanel";
import type {
DashboardStockOrderBookResponse,
DashboardStockSearchItem,
} from "@/features/trade/types/trade.types";
/**
* @description 트레이딩 페이지 메인 컨테이너입니다.
* @see app/(main)/trade/page.tsx 로그인 완료 후 이 컴포넌트를 렌더링합니다.
* @see app/(main)/settings/page.tsx 미인증 상태일 때 설정 페이지로 이동하도록 안내합니다.
* @see features/trade/hooks/useStockSearch.ts 검색 입력/요청/히스토리 상태를 관리합니다.
* @see features/trade/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다.
*/
export function TradeContainer() {
const searchParams = useSearchParams();
const symbolParam = searchParams.get("symbol");
const nameParam = searchParams.get("name");
// [State] 호가 실시간 데이터 (체결 WS와 동일 소켓에서 수신)
const [realtimeOrderBook, setRealtimeOrderBook] =
useState<DashboardStockOrderBookResponse | null>(null);
const { verifiedCredentials, isKisVerified, _hasHydrated } =
useKisRuntimeStore(
useShallow((state) => ({
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
_hasHydrated: state._hasHydrated,
})),
);
const {
keyword,
setKeyword,
searchResults,
setSearchError,
isSearching,
search,
clearSearch,
searchHistory,
appendSearchHistory,
removeSearchHistory,
clearSearchHistory,
} = useStockSearch();
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
useStockOverview();
/**
* [Effect] URL 파라미터(symbol) 감지 시 자동 종목 로드
* 대시보드 등 외부에서 종목 코드를 넘겨받아 트레이딩 페이지로 진입할 때 사용합니다.
*/
useEffect(() => {
if (symbolParam && isKisVerified && verifiedCredentials && _hasHydrated) {
// 현재 선택된 종목과 파라미터가 다를 경우에만 자동 로드 수행
if (selectedStock?.symbol !== symbolParam) {
setKeyword(nameParam || symbolParam);
appendSearchHistory({
symbol: symbolParam,
name: nameParam || symbolParam,
market: "KOSPI", // 기본값 설정, loadOverview 이후 실제 데이터로 보완됨
});
loadOverview(symbolParam, verifiedCredentials);
}
}
}, [
symbolParam,
nameParam,
isKisVerified,
verifiedCredentials,
_hasHydrated,
selectedStock?.symbol,
loadOverview,
setKeyword,
appendSearchHistory,
]);
const canTrade = isKisVerified && !!verifiedCredentials;
const canSearch = canTrade;
const {
searchShellRef,
isSearchPanelOpen,
markSkipNextAutoSearch,
openSearchPanel,
closeSearchPanel,
handleSearchShellBlur,
handleSearchShellKeyDown,
} = useTradeSearchPanel({
canSearch,
keyword,
verifiedCredentials,
search,
clearSearch,
});
/**
* @description 체결 WS에서 전달받은 실시간 호가를 상태에 저장합니다.
* @see features/trade/hooks/useKisTradeWebSocket.ts onOrderBookMessage 콜백
* @see features/trade/hooks/useOrderBook.ts externalRealtimeOrderBook 주입
*/
const handleOrderBookMessage = useCallback(
(data: DashboardStockOrderBookResponse) => {
setRealtimeOrderBook(data);
},
[],
);
// 1. Trade WebSocket (체결 + 호가 통합)
const { latestTick, recentTradeTicks } = useKisTradeWebSocket(
selectedStock?.symbol,
verifiedCredentials,
isKisVerified,
updateRealtimeTradeTick,
{
orderBookSymbol: selectedStock?.symbol,
onOrderBookMessage: handleOrderBookMessage,
},
);
// 2. OrderBook (REST 초기 조회 + WS 실시간 병합)
const { orderBook, isLoading: isOrderBookLoading } = useOrderBook(
selectedStock?.symbol,
selectedStock?.market,
verifiedCredentials,
isKisVerified,
{
enabled: !!selectedStock && !!verifiedCredentials && isKisVerified,
externalRealtimeOrderBook: realtimeOrderBook,
},
);
// 3. Price Calculation Logic (Hook)
const {
currentPrice,
change,
changeRate,
prevClose: referencePrice,
} = useCurrentPrice({
stock: selectedStock,
latestTick,
orderBook,
});
/**
* @description 검색 전 API 인증 여부를 확인합니다.
* @see features/trade/components/search/StockSearchForm.tsx 검색 제출 전 공통 가드로 사용합니다.
*/
const ensureSearchReady = useCallback(() => {
if (canSearch) return true;
setSearchError("API 키 검증을 먼저 완료해 주세요.");
return false;
}, [canSearch, setSearchError]);
/**
* @description 수동 검색 버튼(엔터 포함) 제출 이벤트를 처리합니다.
* @see features/trade/components/search/StockSearchForm.tsx onSubmit 이벤트로 호출됩니다.
*/
const handleSearchSubmit = useCallback(
(event: FormEvent) => {
event.preventDefault();
if (!ensureSearchReady() || !verifiedCredentials) return;
search(keyword, verifiedCredentials);
},
[ensureSearchReady, keyword, search, verifiedCredentials],
);
/**
* @description 검색 결과/히스토리에서 종목을 선택하면 종목 상세를 로드하고 히스토리를 갱신합니다.
* @see features/trade/components/search/StockSearchResults.tsx onSelect 이벤트
* @see features/trade/components/search/StockSearchHistory.tsx onSelect 이벤트
*/
const handleSelectStock = useCallback(
(item: DashboardStockSearchItem) => {
if (!ensureSearchReady() || !verifiedCredentials) return;
// 같은 종목 재선택 시 중복 overview 요청을 막고 검색 목록만 닫습니다.
if (selectedStock?.symbol === item.symbol) {
clearSearch();
closeSearchPanel();
return;
}
// 카드 선택으로 keyword가 바뀔 때 자동 검색이 다시 실행되지 않게 한 번 건너뜁니다.
markSkipNextAutoSearch();
setKeyword(item.name);
clearSearch();
closeSearchPanel();
appendSearchHistory(item);
loadOverview(item.symbol, verifiedCredentials, item.market);
},
[
ensureSearchReady,
verifiedCredentials,
selectedStock?.symbol,
clearSearch,
closeSearchPanel,
setKeyword,
appendSearchHistory,
loadOverview,
markSkipNextAutoSearch,
],
);
if (!_hasHydrated) {
return (
<div className="flex h-full items-center justify-center p-6">
<LoadingSpinner />
</div>
);
}
if (!canTrade) {
return <TradeAccessGate canTrade={canTrade} />;
}
return (
<div className="relative h-full flex flex-col">
{/* ========== SEARCH SECTION ========== */}
<TradeSearchSection
canSearch={canSearch}
isSearchPanelOpen={isSearchPanelOpen}
isSearching={isSearching}
keyword={keyword}
selectedSymbol={selectedStock?.symbol}
searchResults={searchResults}
searchHistory={searchHistory}
searchShellRef={searchShellRef}
onKeywordChange={setKeyword}
onSearchSubmit={handleSearchSubmit}
onSearchFocus={openSearchPanel}
onSearchShellBlur={handleSearchShellBlur}
onSearchShellKeyDown={handleSearchShellKeyDown}
onSelectStock={handleSelectStock}
onRemoveHistory={removeSearchHistory}
onClearHistory={clearSearchHistory}
/>
{/* ========== DASHBOARD SECTION ========== */}
<TradeDashboardContent
selectedStock={selectedStock}
verifiedCredentials={verifiedCredentials}
latestTick={latestTick}
recentTradeTicks={recentTradeTicks}
orderBook={orderBook}
isOrderBookLoading={isOrderBookLoading}
referencePrice={referencePrice}
currentPrice={currentPrice}
change={change}
changeRate={changeRate}
/>
</div>
);
}

View File

@@ -0,0 +1,717 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CandlestickSeries,
ColorType,
HistogramSeries,
createChart,
type IChartApi,
type ISeriesApi,
type Time,
} from "lightweight-charts";
import { ChevronDown } from "lucide-react";
import { useTheme } from "next-themes";
import { toast } from "sonner";
import { fetchStockChart } from "@/features/trade/apis/kis-stock.api";
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import type {
DashboardChartTimeframe,
DashboardRealtimeTradeTick,
StockCandlePoint,
} from "@/features/trade/types/trade.types";
import { cn } from "@/lib/utils";
import {
type ChartBar,
formatKstCrosshairTime,
formatKstTickMark,
formatPrice,
formatSignedPercent,
isMinuteTimeframe,
mergeBars,
normalizeCandles,
toRealtimeTickBar,
upsertRealtimeBar,
} from "./chart-utils";
const UP_COLOR = "#ef4444";
const MINUTE_SYNC_INTERVAL_MS = 30000;
const REALTIME_STALE_THRESHOLD_MS = 12000;
interface ChartPalette {
backgroundColor: string;
downColor: string;
volumeDownColor: string;
textColor: string;
borderColor: string;
gridColor: string;
crosshairColor: string;
}
const DEFAULT_CHART_PALETTE: ChartPalette = {
backgroundColor: "#ffffff",
downColor: "#2563eb",
volumeDownColor: "rgba(37, 99, 235, 0.45)",
textColor: "#6d28d9",
borderColor: "#e9d5ff",
gridColor: "#f3e8ff",
crosshairColor: "#c084fc",
};
function readCssVar(name: string, fallback: string) {
if (typeof window === "undefined") return fallback;
const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
function getChartPaletteFromCssVars(themeMode: "light" | "dark"): ChartPalette {
const isDark = themeMode === "dark";
const backgroundVar = isDark
? "--brand-chart-background-dark"
: "--brand-chart-background-light";
const textVar = isDark ? "--brand-chart-text-dark" : "--brand-chart-text-light";
const borderVar = isDark ? "--brand-chart-border-dark" : "--brand-chart-border-light";
const gridVar = isDark ? "--brand-chart-grid-dark" : "--brand-chart-grid-light";
const crosshairVar = isDark
? "--brand-chart-crosshair-dark"
: "--brand-chart-crosshair-light";
return {
backgroundColor: readCssVar(backgroundVar, DEFAULT_CHART_PALETTE.backgroundColor),
downColor: readCssVar("--brand-chart-down", DEFAULT_CHART_PALETTE.downColor),
volumeDownColor: readCssVar(
"--brand-chart-volume-down",
DEFAULT_CHART_PALETTE.volumeDownColor,
),
textColor: readCssVar(textVar, DEFAULT_CHART_PALETTE.textColor),
borderColor: readCssVar(borderVar, DEFAULT_CHART_PALETTE.borderColor),
gridColor: readCssVar(gridVar, DEFAULT_CHART_PALETTE.gridColor),
crosshairColor: readCssVar(
crosshairVar,
DEFAULT_CHART_PALETTE.crosshairColor,
),
};
}
const MINUTE_TIMEFRAMES: Array<{
value: DashboardChartTimeframe;
label: string;
}> = [
{ value: "1m", label: "1분" },
{ value: "30m", label: "30분" },
{ value: "1h", label: "1시간" },
];
const PERIOD_TIMEFRAMES: Array<{
value: DashboardChartTimeframe;
label: string;
}> = [
{ value: "1d", label: "일" },
{ value: "1w", label: "주" },
];
interface StockLineChartProps {
symbol?: string;
candles: StockCandlePoint[];
credentials?: KisRuntimeCredentials | null;
latestTick?: DashboardRealtimeTradeTick | null;
}
/**
* @description TradingView 스타일 캔들 차트를 렌더링하고, timeframe별 KIS 차트 API를 조회합니다.
* @see features/trade/apis/kis-stock.api.ts fetchStockChart
* @see lib/kis/domestic.ts getDomesticChart
*/
export function StockLineChart({
symbol,
candles,
credentials,
latestTick,
}: StockLineChartProps) {
const { resolvedTheme } = useTheme();
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
const candleSeriesRef = useRef<ISeriesApi<"Candlestick", Time> | null>(null);
const volumeSeriesRef = useRef<ISeriesApi<"Histogram", Time> | null>(null);
const [timeframe, setTimeframe] = useState<DashboardChartTimeframe>("1d");
const [isMinuteDropdownOpen, setIsMinuteDropdownOpen] = useState(false);
const [bars, setBars] = useState<ChartBar[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isChartReady, setIsChartReady] = useState(false);
const lastRealtimeKeyRef = useRef<string>("");
const lastRealtimeAppliedAtRef = useRef(0);
const chartPaletteRef = useRef<ChartPalette>(DEFAULT_CHART_PALETTE);
const renderableBarsRef = useRef<ChartBar[]>([]);
const activeThemeMode: "light" | "dark" =
resolvedTheme === "dark"
? "dark"
: resolvedTheme === "light"
? "light"
: typeof document !== "undefined" &&
document.documentElement.classList.contains("dark")
? "dark"
: "light";
// 복수 이벤트에서 중복 로드를 막기 위한 ref 상태
const loadingMoreRef = useRef(false);
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
const initialLoadCompleteRef = useRef(false);
// API 오류 시 fallback 용도로 유지
const latestCandlesRef = useRef(candles);
useEffect(() => {
latestCandlesRef.current = candles;
}, [candles]);
const latest = bars.at(-1);
const prevClose = bars.length > 1 ? (bars.at(-2)?.close ?? 0) : 0;
const change = latest ? latest.close - prevClose : 0;
const changeRate = prevClose > 0 ? (change / prevClose) * 100 : 0;
const renderableBars = useMemo(() => {
const dedup = new Map<number, ChartBar>();
for (const bar of bars) {
if (
!Number.isFinite(bar.time) ||
!Number.isFinite(bar.open) ||
!Number.isFinite(bar.high) ||
!Number.isFinite(bar.low) ||
!Number.isFinite(bar.close) ||
bar.close <= 0
) {
continue;
}
dedup.set(bar.time, bar);
}
return [...dedup.values()].sort((a, b) => a.time - b.time);
}, [bars]);
useEffect(() => {
renderableBarsRef.current = renderableBars;
}, [renderableBars]);
/**
* @description lightweight-charts 시리즈 데이터에 안전한 OHLCV 값을 주입합니다.
* @see features/trade/components/chart/StockLineChart.tsx renderableBars useMemo
*/
const setSeriesData = useCallback((nextBars: ChartBar[]) => {
const candleSeries = candleSeriesRef.current;
const volumeSeries = volumeSeriesRef.current;
if (!candleSeries || !volumeSeries) return;
try {
candleSeries.setData(
nextBars.map((bar) => ({
time: bar.time,
open: bar.open,
high: bar.high,
low: bar.low,
close: bar.close,
})),
);
volumeSeries.setData(
nextBars.map((bar) => ({
time: bar.time,
value: Number.isFinite(bar.volume) ? bar.volume : 0,
color:
bar.close >= bar.open
? "rgba(239,68,68,0.45)"
: chartPaletteRef.current.volumeDownColor,
})),
);
} catch (error) {
console.error("Failed to render chart series data:", error);
}
}, []);
/**
* @description 좌측 스크롤 시 cursor 기반 과거 캔들을 추가 로드합니다.
* @see lib/kis/domestic.ts getDomesticChart cursor
*/
const handleLoadMore = useCallback(async () => {
if (!symbol || !credentials || !nextCursor || loadingMoreRef.current) return;
loadingMoreRef.current = true;
setIsLoadingMore(true);
try {
const response = await fetchStockChart(
symbol,
timeframe,
credentials,
nextCursor,
);
const olderBars = normalizeCandles(response.candles, timeframe);
setBars((prev) => mergeBars(olderBars, prev));
setNextCursor(response.hasMore ? response.nextCursor : null);
} catch (error) {
const message =
error instanceof Error
? error.message
: "과거 차트 데이터를 불러오지 못했습니다.";
toast.error(message);
} finally {
loadingMoreRef.current = false;
setIsLoadingMore(false);
}
}, [credentials, nextCursor, symbol, timeframe]);
useEffect(() => {
loadMoreHandlerRef.current = handleLoadMore;
}, [handleLoadMore]);
useEffect(() => {
lastRealtimeKeyRef.current = "";
lastRealtimeAppliedAtRef.current = 0;
}, [symbol, timeframe]);
useEffect(() => {
const container = containerRef.current;
if (!container || chartRef.current) return;
// 브랜드 색상은 globals.css의 CSS 변수에서 읽어 차트(canvas)에도 동일하게 적용합니다.
const palette = getChartPaletteFromCssVars(activeThemeMode);
chartPaletteRef.current = palette;
const chart = createChart(container, {
width: Math.max(container.clientWidth, 320),
height: Math.max(container.clientHeight, 340),
layout: {
background: { type: ColorType.Solid, color: palette.backgroundColor },
textColor: palette.textColor,
attributionLogo: true,
},
localization: {
locale: "ko-KR",
timeFormatter: formatKstCrosshairTime,
},
rightPriceScale: {
borderColor: palette.borderColor,
scaleMargins: {
top: 0.08,
bottom: 0.24,
},
},
grid: {
vertLines: { color: palette.gridColor },
horzLines: { color: palette.gridColor },
},
crosshair: {
vertLine: { color: palette.crosshairColor, width: 1, style: 2 },
horzLine: { color: palette.crosshairColor, width: 1, style: 2 },
},
timeScale: {
borderColor: palette.borderColor,
timeVisible: true,
secondsVisible: false,
rightOffset: 2,
tickMarkFormatter: formatKstTickMark,
},
handleScroll: {
mouseWheel: true,
pressedMouseMove: true,
},
handleScale: {
mouseWheel: true,
pinch: true,
axisPressedMouseMove: true,
},
});
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: UP_COLOR,
downColor: palette.downColor,
wickUpColor: UP_COLOR,
wickDownColor: palette.downColor,
borderUpColor: UP_COLOR,
borderDownColor: palette.downColor,
priceLineVisible: true,
lastValueVisible: true,
});
const volumeSeries = chart.addSeries(HistogramSeries, {
priceScaleId: "volume",
priceLineVisible: false,
lastValueVisible: false,
base: 0,
});
chart.priceScale("volume").applyOptions({
scaleMargins: {
top: 0.78,
bottom: 0,
},
borderVisible: false,
});
let scrollTimeout: number | undefined;
chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
if (!range || !initialLoadCompleteRef.current) return;
if (range.from >= 10) return;
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
scrollTimeout = window.setTimeout(() => {
void loadMoreHandlerRef.current();
}, 250);
});
chartRef.current = chart;
candleSeriesRef.current = candleSeries;
volumeSeriesRef.current = volumeSeries;
setIsChartReady(true);
const resizeObserver = new ResizeObserver(() => {
chart.resize(
Math.max(container.clientWidth, 320),
Math.max(container.clientHeight, 340),
);
});
resizeObserver.observe(container);
const rafId = window.requestAnimationFrame(() => {
chart.resize(
Math.max(container.clientWidth, 320),
Math.max(container.clientHeight, 340),
);
});
return () => {
if (scrollTimeout !== undefined) window.clearTimeout(scrollTimeout);
window.cancelAnimationFrame(rafId);
resizeObserver.disconnect();
chart.remove();
chartRef.current = null;
candleSeriesRef.current = null;
volumeSeriesRef.current = null;
setIsChartReady(false);
};
}, [activeThemeMode]);
useEffect(() => {
const chart = chartRef.current;
const candleSeries = candleSeriesRef.current;
if (!chart || !candleSeries) return;
const palette = getChartPaletteFromCssVars(activeThemeMode);
chartPaletteRef.current = palette;
chart.applyOptions({
layout: {
background: { type: ColorType.Solid, color: palette.backgroundColor },
textColor: palette.textColor,
},
rightPriceScale: { borderColor: palette.borderColor },
grid: {
vertLines: { color: palette.gridColor },
horzLines: { color: palette.gridColor },
},
crosshair: {
vertLine: { color: palette.crosshairColor, width: 1, style: 2 },
horzLine: { color: palette.crosshairColor, width: 1, style: 2 },
},
timeScale: { borderColor: palette.borderColor },
});
candleSeries.applyOptions({
downColor: palette.downColor,
wickDownColor: palette.downColor,
borderDownColor: palette.downColor,
});
setSeriesData(renderableBarsRef.current);
}, [activeThemeMode, setSeriesData]);
useEffect(() => {
if (symbol && credentials) return;
// 인증 전/종목 미선택 상태는 overview 캔들로 fallback
setBars(normalizeCandles(candles, "1d"));
setNextCursor(null);
}, [candles, credentials, symbol]);
useEffect(() => {
if (!symbol || !credentials) return;
initialLoadCompleteRef.current = false;
let disposed = false;
const load = async () => {
setIsLoading(true);
try {
const firstPage = await fetchStockChart(symbol, timeframe, credentials);
if (disposed) return;
let mergedBars = normalizeCandles(firstPage.candles, timeframe);
let resolvedNextCursor = firstPage.hasMore ? firstPage.nextCursor : null;
// 분봉은 기본 3페이지까지 순차 조회해 오전 구간 누락을 줄입니다.
if (
isMinuteTimeframe(timeframe) &&
firstPage.hasMore &&
firstPage.nextCursor
) {
let minuteCursor: string | null = firstPage.nextCursor;
let extraPageCount = 0;
while (minuteCursor && extraPageCount < 2) {
try {
const olderPage = await fetchStockChart(
symbol,
timeframe,
credentials,
minuteCursor,
);
const olderBars = normalizeCandles(olderPage.candles, timeframe);
mergedBars = mergeBars(olderBars, mergedBars);
resolvedNextCursor = olderPage.hasMore ? olderPage.nextCursor : null;
minuteCursor = olderPage.hasMore ? olderPage.nextCursor : null;
extraPageCount += 1;
} catch {
// 추가 페이지 실패는 치명적이지 않으므로 현재 데이터는 유지합니다.
minuteCursor = null;
}
}
}
setBars(mergedBars);
setNextCursor(resolvedNextCursor);
window.setTimeout(() => {
if (!disposed) initialLoadCompleteRef.current = true;
}, 350);
} catch (error) {
if (disposed) return;
const message =
error instanceof Error
? error.message
: "차트 조회 중 오류가 발생했습니다.";
toast.error(message);
setBars(normalizeCandles(latestCandlesRef.current, timeframe));
setNextCursor(null);
} finally {
if (!disposed) setIsLoading(false);
}
};
void load();
return () => {
disposed = true;
};
}, [credentials, symbol, timeframe]);
useEffect(() => {
if (!isChartReady) return;
setSeriesData(renderableBars);
if (!initialLoadCompleteRef.current && renderableBars.length > 0) {
chartRef.current?.timeScale().fitContent();
}
}, [isChartReady, renderableBars, setSeriesData]);
/**
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
* @see features/trade/hooks/useKisTradeWebSocket.ts latestTick
* @see features/trade/components/chart/chart-utils.ts toRealtimeTickBar
*/
useEffect(() => {
if (!latestTick) return;
if (bars.length === 0) return;
const dedupeKey = `${timeframe}:${latestTick.tickTime}:${latestTick.price}:${latestTick.tradeVolume}`;
if (lastRealtimeKeyRef.current === dedupeKey) return;
const realtimeBar = toRealtimeTickBar(latestTick, timeframe);
if (!realtimeBar) return;
lastRealtimeKeyRef.current = dedupeKey;
lastRealtimeAppliedAtRef.current = Date.now();
setBars((prev) => upsertRealtimeBar(prev, realtimeBar));
}, [bars.length, latestTick, timeframe]);
/**
* @description 분봉(1m/30m/1h)에서는 WS 공백 상황을 대비해 최신 페이지를 주기적으로 동기화합니다.
* @see features/trade/apis/kis-stock.api.ts fetchStockChart
* @see lib/kis/domestic.ts getDomesticChart
*/
useEffect(() => {
if (!symbol || !credentials) return;
if (!isMinuteTimeframe(timeframe)) return;
let disposed = false;
const syncLatestMinuteBars = async () => {
const now = Date.now();
const isRealtimeFresh =
now - lastRealtimeAppliedAtRef.current < REALTIME_STALE_THRESHOLD_MS;
if (isRealtimeFresh) return;
try {
const response = await fetchStockChart(symbol, timeframe, credentials);
if (disposed) return;
const latestPageBars = normalizeCandles(response.candles, timeframe);
const recentBars = latestPageBars.slice(-10);
if (recentBars.length === 0) return;
setBars((prev) => {
const merged = mergeBars(prev, recentBars);
return areBarsEqual(prev, merged) ? prev : merged;
});
} catch {
// 폴링 실패는 치명적이지 않으므로 조용히 다음 주기에서 재시도합니다.
}
};
const intervalId = window.setInterval(() => {
void syncLatestMinuteBars();
}, MINUTE_SYNC_INTERVAL_MS);
return () => {
disposed = true;
window.clearInterval(intervalId);
};
}, [credentials, symbol, timeframe]);
const statusMessage = (() => {
if (isLoading && bars.length === 0) {
return "차트 데이터를 불러오는 중입니다.";
}
if (bars.length === 0) {
return "차트 데이터가 없습니다.";
}
if (renderableBars.length === 0) {
return "차트 데이터 형식이 올바르지 않습니다.";
}
return null;
})();
return (
<div className="flex h-full min-h-[340px] flex-col bg-white dark:bg-brand-900/10">
{/* ========== CHART TOOLBAR ========== */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-brand-100 bg-muted/20 px-2 py-2 sm:px-3 dark:border-brand-800/45 dark:bg-brand-900/35">
<div className="flex flex-wrap items-center gap-1 text-xs sm:text-sm">
<div className="relative">
<button
type="button"
onClick={() => setIsMinuteDropdownOpen((prev) => !prev)}
onBlur={() =>
window.setTimeout(() => setIsMinuteDropdownOpen(false), 200)
}
className={cn(
"relative flex items-center gap-1 border-b-2 border-transparent px-2 py-1.5 text-muted-foreground transition-colors hover:text-foreground dark:text-brand-100/72 dark:hover:text-brand-50",
MINUTE_TIMEFRAMES.some((item) => item.value === timeframe) &&
"border-brand-400 font-semibold text-foreground dark:text-brand-50",
)}
>
{MINUTE_TIMEFRAMES.find((item) => item.value === timeframe)
?.label ?? "분봉"}
<ChevronDown className="h-3 w-3" />
</button>
{isMinuteDropdownOpen && (
<div className="absolute left-0 top-full z-10 mt-1 rounded border border-brand-100 bg-white shadow-lg dark:border-brand-700/45 dark:bg-[#1a1624]">
{MINUTE_TIMEFRAMES.map((item) => (
<button
key={item.value}
type="button"
onClick={() => {
setTimeframe(item.value);
setIsMinuteDropdownOpen(false);
}}
className={cn(
"block w-full whitespace-nowrap px-3 py-1.5 text-left hover:bg-brand-50 dark:text-brand-100 dark:hover:bg-brand-800/35",
timeframe === item.value &&
"bg-brand-50 font-semibold text-brand-700 dark:bg-brand-700/30 dark:text-brand-100",
)}
>
{item.label}
</button>
))}
</div>
)}
</div>
{PERIOD_TIMEFRAMES.map((item) => (
<button
key={item.value}
type="button"
onClick={() => setTimeframe(item.value)}
className={cn(
"relative border-b-2 border-transparent px-2 py-1.5 text-muted-foreground transition-colors hover:text-foreground dark:text-brand-100/72 dark:hover:text-brand-50",
timeframe === item.value &&
"border-brand-400 font-semibold text-foreground dark:text-brand-50",
)}
>
{item.label}
</button>
))}
{isLoadingMore && (
<span className="ml-2 text-[11px] text-muted-foreground dark:text-brand-200/70">
...
</span>
)}
</div>
<div className="text-[11px] text-muted-foreground dark:text-brand-100/85 sm:text-xs">
O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)} L{" "}
{formatPrice(latest?.low ?? 0)} C{" "}
<span
className={cn(
change >= 0 ? "text-red-600" : "text-blue-600 dark:text-blue-400",
)}
>
{formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)})
</span>
</div>
</div>
{/* ========== CHART BODY ========== */}
<div className="relative min-h-0 flex-1 overflow-hidden">
<div ref={containerRef} className="h-full w-full" />
{statusMessage && (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 text-sm text-muted-foreground dark:bg-background/90 dark:text-brand-100/80">
{statusMessage}
</div>
)}
</div>
</div>
);
}
function areBarsEqual(left: ChartBar[], right: ChartBar[]) {
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
const lhs = left[index];
const rhs = right[index];
if (!lhs || !rhs) return false;
if (
lhs.time !== rhs.time ||
lhs.open !== rhs.open ||
lhs.high !== rhs.high ||
lhs.low !== rhs.low ||
lhs.close !== rhs.close ||
lhs.volume !== rhs.volume
) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,350 @@
/**
* @file chart-utils.ts
* @description StockLineChart에서 사용하는 유틸리티 함수 모음
*/
import type {
TickMarkType,
Time,
UTCTimestamp,
} from "lightweight-charts";
import type {
DashboardChartTimeframe,
DashboardRealtimeTradeTick,
StockCandlePoint,
} from "@/features/trade/types/trade.types";
const KRW_FORMATTER = new Intl.NumberFormat("ko-KR");
const KST_TIME_ZONE = "Asia/Seoul";
const KST_TIME_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
timeZone: KST_TIME_ZONE,
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const KST_TIME_SECONDS_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
timeZone: KST_TIME_ZONE,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const KST_DATE_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
timeZone: KST_TIME_ZONE,
month: "2-digit",
day: "2-digit",
});
const KST_MONTH_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
timeZone: KST_TIME_ZONE,
month: "short",
});
const KST_YEAR_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
timeZone: KST_TIME_ZONE,
year: "numeric",
});
const KST_CROSSHAIR_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
timeZone: KST_TIME_ZONE,
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
// ─── 타입 ──────────────────────────────────────────────────
export type ChartBar = {
time: UTCTimestamp;
open: number;
high: number;
low: number;
close: number;
volume: number;
};
// ─── StockCandlePoint → ChartBar 변환 ─────────────────────
/**
* candles 배열을 ChartBar 배열로 정규화 (무효값 필터 + 병합 + 정렬)
*/
export function normalizeCandles(
candles: StockCandlePoint[],
timeframe: DashboardChartTimeframe,
): ChartBar[] {
const rows = candles
.map((c) => convertCandleToBar(c, timeframe))
.filter((b): b is ChartBar => Boolean(b));
return mergeBars([], rows);
}
/**
* 단일 candle → ChartBar 변환. 유효하지 않으면 null
*/
export function convertCandleToBar(
candle: StockCandlePoint,
timeframe: DashboardChartTimeframe,
): ChartBar | null {
const close = candle.close ?? candle.price;
if (!Number.isFinite(close) || close <= 0) return null;
const open = candle.open ?? close;
const high = candle.high ?? Math.max(open, close);
const low = candle.low ?? Math.min(open, close);
const volume = candle.volume ?? 0;
const time = resolveBarTimestamp(candle, timeframe);
if (!time) return null;
return {
time,
open,
high: Math.max(high, open, close),
low: Math.min(low, open, close),
close,
volume,
};
}
// ─── 타임스탬프 해석/정렬 ─────────────────────────────────
function resolveBarTimestamp(
candle: StockCandlePoint,
timeframe: DashboardChartTimeframe,
): UTCTimestamp | null {
// timestamp 필드가 있으면 우선 사용
if (
typeof candle.timestamp === "number" &&
Number.isFinite(candle.timestamp)
) {
return alignTimestamp(candle.timestamp, timeframe);
}
const text = typeof candle.time === "string" ? candle.time.trim() : "";
if (!text) return null;
// "MM/DD" 형식 (일봉)
if (/^\d{2}\/\d{2}$/.test(text)) {
const [mm, dd] = text.split("/");
const year = new Date().getFullYear();
const ts = Math.floor(
new Date(`${year}-${mm}-${dd}T09:00:00+09:00`).getTime() / 1000,
);
return alignTimestamp(ts, timeframe);
}
// "HH:MM" 또는 "HH:MM:SS" 형식 (분봉)
if (/^\d{2}:\d{2}(:\d{2})?$/.test(text)) {
const [hh, mi, ss] = text.split(":");
const now = new Date();
const y = now.getFullYear();
const m = `${now.getMonth() + 1}`.padStart(2, "0");
const d = `${now.getDate()}`.padStart(2, "0");
const ts = Math.floor(
new Date(`${y}-${m}-${d}T${hh}:${mi}:${ss ?? "00"}+09:00`).getTime() /
1000,
);
return alignTimestamp(ts, timeframe);
}
return null;
}
/**
* 타임스탬프를 타임프레임 버킷 경계에 정렬
* - 1m: 초/밀리초를 제거해 분 경계에 정렬
* - 30m/1h: 분 단위를 버킷에 정렬
* - 1d: 00:00:00
* - 1w: 월요일 00:00:00
*/
function alignTimestamp(
timestamp: number,
timeframe: DashboardChartTimeframe,
): UTCTimestamp {
const d = new Date(timestamp * 1000);
if (timeframe === "1m") {
d.setUTCSeconds(0, 0);
} else if (timeframe === "30m" || timeframe === "1h") {
const bucket = timeframe === "30m" ? 30 : 60;
d.setUTCMinutes(Math.floor(d.getUTCMinutes() / bucket) * bucket, 0, 0);
} else if (timeframe === "1d") {
d.setUTCHours(0, 0, 0, 0);
} else if (timeframe === "1w") {
const day = d.getUTCDay();
d.setUTCDate(d.getUTCDate() + (day === 0 ? -6 : 1 - day));
d.setUTCHours(0, 0, 0, 0);
}
return Math.floor(d.getTime() / 1000) as UTCTimestamp;
}
// ─── 봉 병합 ──────────────────────────────────────────────
/**
* 두 ChartBar 배열을 시간 기준으로 병합. 같은 시간대는 OHLCV 통합
*/
export function mergeBars(left: ChartBar[], right: ChartBar[]): ChartBar[] {
const map = new Map<number, ChartBar>();
for (const bar of [...left, ...right]) {
const prev = map.get(bar.time);
if (!prev) {
map.set(bar.time, bar);
continue;
}
map.set(bar.time, {
time: bar.time,
open: prev.open,
high: Math.max(prev.high, bar.high),
low: Math.min(prev.low, bar.low),
close: bar.close,
volume: Math.max(prev.volume, bar.volume),
});
}
return [...map.values()].sort((a, b) => a.time - b.time);
}
/**
* 실시간 봉 업데이트: 같은 시간이면 기존 봉에 병합, 새 시간이면 추가
*/
export function upsertRealtimeBar(
prev: ChartBar[],
incoming: ChartBar,
): ChartBar[] {
if (prev.length === 0) return [incoming];
const last = prev[prev.length - 1];
if (incoming.time > last.time) return [...prev, incoming];
if (incoming.time < last.time) return prev;
return [
...prev.slice(0, -1),
{
time: last.time,
open: last.open,
high: Math.max(last.high, incoming.high),
low: Math.min(last.low, incoming.low),
close: incoming.close,
volume: Math.max(last.volume, incoming.volume),
},
];
}
/**
* @description 실시간 체결 틱을 차트용 ChartBar로 변환합니다. (KST 날짜 + tickTime 기준)
* @see features/trade/hooks/useKisTradeWebSocket.ts latestTick
* @see features/trade/components/chart/StockLineChart.tsx 실시간 캔들 반영
*/
export function toRealtimeTickBar(
tick: DashboardRealtimeTradeTick,
timeframe: DashboardChartTimeframe,
now = new Date(),
): ChartBar | null {
if (!Number.isFinite(tick.price) || tick.price <= 0) return null;
const hhmmss = normalizeTickTime(tick.tickTime);
if (!hhmmss) return null;
const ymd = getKstYmd(now);
const baseTimestamp = toKstTimestamp(ymd, hhmmss);
const alignedTimestamp = alignTimestamp(baseTimestamp, timeframe);
const minuteFrame = isMinuteTimeframe(timeframe);
return {
time: alignedTimestamp,
open: minuteFrame ? tick.price : Math.max(tick.open, tick.price),
high: minuteFrame ? tick.price : Math.max(tick.high, tick.price),
low: minuteFrame ? tick.price : Math.min(tick.low || tick.price, tick.price),
close: tick.price,
volume: minuteFrame
? Math.max(tick.tradeVolume, 0)
: Math.max(tick.accumulatedVolume, 0),
};
}
/**
* @description lightweight-charts X축 라벨을 KST 기준으로 강제 포맷합니다.
* @see features/trade/components/chart/StockLineChart.tsx createChart options.timeScale.tickMarkFormatter
*/
export function formatKstTickMark(time: Time, tickMarkType: TickMarkType) {
const date = toDateFromChartTime(time);
if (!date) return null;
if (tickMarkType === 0) return KST_YEAR_FORMATTER.format(date);
if (tickMarkType === 1) return KST_MONTH_FORMATTER.format(date);
if (tickMarkType === 2) return KST_DATE_FORMATTER.format(date);
if (tickMarkType === 4) return KST_TIME_SECONDS_FORMATTER.format(date);
return KST_TIME_FORMATTER.format(date);
}
/**
* @description crosshair 시간 라벨을 KST로 포맷합니다.
* @see features/trade/components/chart/StockLineChart.tsx createChart options.localization.timeFormatter
*/
export function formatKstCrosshairTime(time: Time) {
const date = toDateFromChartTime(time);
if (!date) return "";
return KST_CROSSHAIR_FORMATTER.format(date);
}
// ─── 포맷터 ───────────────────────────────────────────────
export function formatPrice(value: number) {
return KRW_FORMATTER.format(Math.round(value));
}
export function formatSignedPercent(value: number) {
const sign = value > 0 ? "+" : "";
return `${sign}${value.toFixed(2)}%`;
}
/**
* 분봉 타임프레임인지 판별
*/
export function isMinuteTimeframe(tf: DashboardChartTimeframe) {
return tf === "1m" || tf === "30m" || tf === "1h";
}
function normalizeTickTime(value?: string) {
if (!value) return null;
const normalized = value.trim();
return /^\d{6}$/.test(normalized) ? normalized : null;
}
function getKstYmd(now = new Date()) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: KST_TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(now);
const map = new Map(parts.map((part) => [part.type, part.value]));
return `${map.get("year")}${map.get("month")}${map.get("day")}`;
}
function toKstTimestamp(yyyymmdd: string, hhmmss: string) {
const y = Number(yyyymmdd.slice(0, 4));
const m = Number(yyyymmdd.slice(4, 6));
const d = Number(yyyymmdd.slice(6, 8));
const hh = Number(hhmmss.slice(0, 2));
const mm = Number(hhmmss.slice(2, 4));
const ss = Number(hhmmss.slice(4, 6));
return Math.floor(Date.UTC(y, m - 1, d, hh - 9, mm, ss) / 1000);
}
function toDateFromChartTime(time: Time) {
if (typeof time === "number" && Number.isFinite(time)) {
return new Date(time * 1000);
}
if (typeof time === "string") {
const parsed = Date.parse(time);
return Number.isFinite(parsed) ? new Date(parsed) : null;
}
if (time && typeof time === "object" && "year" in time) {
const { year, month, day } = time;
return new Date(Date.UTC(year, month - 1, day));
}
return null;
}

View File

@@ -0,0 +1,143 @@
import { Activity, ShieldCheck } from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { StockLineChart } from "@/features/trade/components/chart/StockLineChart";
import { StockPriceBadge } from "@/features/trade/components/details/StockPriceBadge";
import type {
DashboardStockItem,
DashboardPriceSource,
DashboardMarketPhase,
} from "@/features/trade/types/trade.types";
const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR");
function formatVolume(value: number) {
return `${PRICE_FORMATTER.format(value)}`;
}
function getPriceSourceLabel(
source: DashboardPriceSource,
marketPhase: DashboardMarketPhase,
) {
switch (source) {
case "inquire-overtime-price":
return "시간외 현재가(inquire-overtime-price)";
case "inquire-ccnl":
return marketPhase === "afterHours"
? "체결가 폴백(inquire-ccnl)"
: "체결가(inquire-ccnl)";
default:
return "현재가(inquire-price)";
}
}
function PriceStat({ label, value }: { label: string; value: string }) {
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="mt-1 text-sm font-semibold text-foreground">{value}</p>
</div>
);
}
interface StockOverviewCardProps {
stock: DashboardStockItem;
priceSource: DashboardPriceSource;
marketPhase: DashboardMarketPhase;
isRealtimeConnected: boolean;
realtimeTrId: string | null;
lastRealtimeTickAt: number | null;
}
export function StockOverviewCard({
stock,
priceSource,
marketPhase,
isRealtimeConnected,
realtimeTrId,
lastRealtimeTickAt,
}: StockOverviewCardProps) {
const apiPriceSourceLabel = getPriceSourceLabel(priceSource, marketPhase);
const effectivePriceSourceLabel =
isRealtimeConnected && lastRealtimeTickAt
? `실시간 체결(WebSocket ${realtimeTrId || ""})`
: apiPriceSourceLabel;
return (
<Card className="overflow-hidden border-brand-200">
<CardHeader className="border-b border-border/50 bg-muted/30 pb-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-xl font-bold">{stock.name}</CardTitle>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{stock.symbol}
</span>
<span className="rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700">
{stock.market}
</span>
</div>
<CardDescription className="mt-1 flex items-center gap-1.5">
<span>{effectivePriceSourceLabel}</span>
{isRealtimeConnected && (
<span className="inline-flex items-center gap-1 rounded bg-brand-100 px-1.5 py-0.5 text-xs font-medium text-brand-700">
<Activity className="h-3 w-3" />
</span>
)}
</CardDescription>
</div>
<div className="flex flex-col items-end gap-1">
<StockPriceBadge
currentPrice={stock.currentPrice}
change={stock.change}
changeRate={stock.changeRate}
/>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="grid border-b border-border/50 lg:grid-cols-3">
<div className="col-span-2 border-r border-border/50">
{/* Chart Area */}
<div className="p-6">
<StockLineChart candles={stock.candles} />
</div>
</div>
<div className="col-span-1 bg-muted/10 p-6">
<div className="mb-4 flex items-center gap-2 text-sm font-semibold text-foreground/80">
<ShieldCheck className="h-4 w-4 text-brand-600" />
</div>
<div className="grid grid-cols-2 gap-3">
<PriceStat
label="시가"
value={`${PRICE_FORMATTER.format(stock.open)}`}
/>
<PriceStat
label="고가"
value={`${PRICE_FORMATTER.format(stock.high)}`}
/>
<PriceStat
label="저가"
value={`${PRICE_FORMATTER.format(stock.low)}`}
/>
<PriceStat
label="전일종가"
value={`${PRICE_FORMATTER.format(stock.prevClose)}`}
/>
<div className="col-span-2">
<PriceStat label="거래량" value={formatVolume(stock.volume)} />
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,48 @@
import { TrendingDown, TrendingUp } from "lucide-react";
import { cn } from "@/lib/utils";
const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR");
function formatPrice(value: number) {
return `${PRICE_FORMATTER.format(value)}`;
}
interface StockPriceBadgeProps {
currentPrice: number;
change: number;
changeRate: number;
}
export function StockPriceBadge({
currentPrice,
change,
changeRate,
}: StockPriceBadgeProps) {
const isPositive = change >= 0;
const ChangeIcon = isPositive ? TrendingUp : TrendingDown;
const changeColor = isPositive ? "text-red-500" : "text-brand-600";
const changeSign = isPositive ? "+" : "";
return (
<div className="flex items-baseline gap-2">
<span className={cn("text-3xl font-bold", changeColor)}>
{formatPrice(currentPrice)}
</span>
<div
className={cn(
"flex items-center gap-1 text-sm font-medium",
changeColor,
)}
>
<ChangeIcon className="h-4 w-4" />
<span>
{changeSign}
{PRICE_FORMATTER.format(change)}
</span>
<span>
({changeSign}
{changeRate.toFixed(2)}%)
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
interface TradeAccessGateProps {
canTrade: boolean;
}
/**
* @description KIS 인증 여부에 따라 트레이드 화면 접근 가이드를 렌더링합니다.
* @see features/trade/components/TradeContainer.tsx TradeContainer의 인증 가드 UI를 분리합니다.
* @see app/(main)/settings/page.tsx 미인증 사용자를 설정 페이지로 이동시킵니다.
*/
export function TradeAccessGate({ canTrade }: TradeAccessGateProps) {
if (canTrade) 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,89 @@
// import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { DashboardStockItem } from "@/features/trade/types/trade.types";
import { cn } from "@/lib/utils";
interface StockHeaderProps {
stock: DashboardStockItem;
price: string;
change: string;
changeRate: string;
high?: string;
low?: string;
volume?: string;
}
export function StockHeader({
stock,
price,
change,
changeRate,
high,
low,
volume,
}: StockHeaderProps) {
const isRise = changeRate.startsWith("+") || parseFloat(changeRate) > 0;
const isFall = changeRate.startsWith("-") || parseFloat(changeRate) < 0;
const colorClass = isRise
? "text-red-500"
: isFall
? "text-blue-600 dark:text-blue-400"
: "text-foreground";
return (
<div className="bg-white px-3 py-2 dark:bg-brand-900/22 sm:px-4 sm:py-3">
{/* ========== STOCK SUMMARY ========== */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="truncate text-lg font-bold leading-tight text-foreground dark:text-brand-50 sm:text-xl">
{stock.name}
</h1>
<span className="mt-0.5 block text-xs text-muted-foreground dark:text-brand-100/70 sm:text-sm">
{stock.symbol}/{stock.market}
</span>
</div>
<div className={cn("shrink-0 text-right", colorClass)}>
<span className="block text-2xl font-bold tracking-tight">{price}</span>
<span className="text-xs font-medium sm:text-sm">
{changeRate}% <span className="ml-1 text-[11px] sm:text-xs">{change}</span>
</span>
</div>
</div>
{/* ========== STATS ========== */}
<div className="mt-2 grid grid-cols-3 gap-2 text-xs md:hidden">
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70"></p>
<p className="font-medium text-red-500">{high || "--"}</p>
</div>
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70"></p>
<p className="font-medium text-blue-600 dark:text-blue-400">{low || "--"}</p>
</div>
<div className="rounded-md bg-muted/40 px-2 py-1.5 dark:border dark:border-brand-800/45 dark:bg-brand-900/25">
<p className="text-[11px] text-muted-foreground dark:text-brand-100/70">(24H)</p>
<p className="font-medium">{volume || "--"}</p>
</div>
</div>
<Separator className="mt-2 md:hidden" />
{/* ========== DESKTOP STATS ========== */}
<div className="hidden items-center justify-end gap-6 pt-1 text-sm md:flex">
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs dark:text-brand-100/70"></span>
<span className="font-medium text-red-500">{high || "--"}</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs dark:text-brand-100/70"></span>
<span className="font-medium text-blue-600 dark:text-blue-400">{low || "--"}</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs dark:text-brand-100/70">(24H)</span>
<span className="font-medium">{volume || "--"}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface DashboardLayoutProps {
header: ReactNode;
chart: ReactNode;
orderBook: ReactNode;
orderForm: ReactNode;
className?: string;
}
export function DashboardLayout({
header,
chart,
orderBook,
orderForm,
className,
}: DashboardLayoutProps) {
return (
<div
className={cn(
"flex flex-col bg-background dark:bg-[linear-gradient(135deg,rgba(53,35,86,0.18),rgba(13,13,20,0.98))]",
// Mobile: Scrollable page height
"min-h-[calc(100vh-64px)]",
// Desktop: Fixed height, no window scroll
"xl:h-[calc(100vh-64px)] xl:overflow-hidden",
className,
)}
>
{/* 1. Header Area */}
<div className="flex-none border-b border-brand-100 bg-white dark:border-brand-800/45 dark:bg-brand-900/22">
{header}
</div>
{/* 2. Main Content Area */}
<div
className={cn(
"flex flex-1 flex-col",
// Mobile: Allow content to flow naturally with spacing
"overflow-visible pb-4 gap-4",
// Desktop: Internal scrolling, horizontal layout, no page spacing
"xl:overflow-hidden xl:flex-row xl:pb-0 xl:gap-0",
)}
>
{/* Left Column: Chart & Info */}
<div
className={cn(
"flex flex-col border-border dark:border-brand-800/45",
// Mobile: Fixed height for chart to ensure visibility
"h-[320px] flex-none border-b sm:h-[360px]",
// Desktop: Fill remaining space, remove bottom border, add right border
"xl:flex-1 xl:h-auto xl:min-h-0 xl:min-w-0 xl:border-b-0 xl:border-r",
)}
>
<div className="flex-1 min-h-0">{chart}</div>
{/* Future: Transaction History / Market Depth can go here */}
</div>
{/* Right Column: Order Book & Order Form */}
<div className="flex min-h-0 w-full flex-none flex-col bg-background dark:bg-brand-900/12 xl:w-[460px] xl:pr-2 2xl:w-[500px]">
{/* Top: Order Book (Hoga) */}
<div className="h-[390px] flex-none overflow-hidden border-t border-border dark:border-brand-800/45 sm:h-[430px] xl:min-h-0 xl:flex-1 xl:h-auto xl:border-t-0 xl:border-b">
{orderBook}
</div>
{/* Bottom: Order Form */}
<div className="flex-none h-auto sm:h-auto xl:h-[380px]">
{orderForm}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import { StockLineChart } from "@/features/trade/components/chart/StockLineChart";
import { StockHeader } from "@/features/trade/components/header/StockHeader";
import { DashboardLayout } from "@/features/trade/components/layout/DashboardLayout";
import { OrderForm } from "@/features/trade/components/order/OrderForm";
import { OrderBook } from "@/features/trade/components/orderbook/OrderBook";
import type {
DashboardRealtimeTradeTick,
DashboardStockItem,
DashboardStockOrderBookResponse,
} from "@/features/trade/types/trade.types";
import { cn } from "@/lib/utils";
interface TradeDashboardContentProps {
selectedStock: DashboardStockItem | null;
verifiedCredentials: KisRuntimeCredentials | null;
latestTick: DashboardRealtimeTradeTick | null;
recentTradeTicks: DashboardRealtimeTradeTick[];
orderBook: DashboardStockOrderBookResponse | null;
isOrderBookLoading: boolean;
referencePrice?: number;
currentPrice?: number;
change?: number;
changeRate?: number;
}
/**
* @description 트레이드 본문(헤더/차트/호가/주문)을 조합해서 렌더링합니다.
* @see features/trade/components/TradeContainer.tsx TradeContainer가 화면 조합 코드를 단순화하기 위해 사용합니다.
* @see features/trade/components/layout/DashboardLayout.tsx 실제 4분할 레이아웃은 DashboardLayout에서 처리합니다.
*/
export function TradeDashboardContent({
selectedStock,
verifiedCredentials,
latestTick,
recentTradeTicks,
orderBook,
isOrderBookLoading,
referencePrice,
currentPrice,
change,
changeRate,
}: TradeDashboardContentProps) {
return (
<div
className={cn(
"transition-opacity duration-200 h-full flex-1 min-h-0 overflow-x-hidden",
!selectedStock && "opacity-20 pointer-events-none",
)}
>
{/* ========== DASHBOARD LAYOUT ========== */}
<DashboardLayout
header={
selectedStock ? (
<StockHeader
stock={selectedStock}
price={currentPrice?.toLocaleString() ?? "0"}
change={change?.toLocaleString() ?? "0"}
changeRate={changeRate?.toFixed(2) ?? "0.00"}
high={latestTick ? latestTick.high.toLocaleString() : undefined}
low={latestTick ? latestTick.low.toLocaleString() : undefined}
volume={
latestTick ? latestTick.accumulatedVolume.toLocaleString() : undefined
}
/>
) : null
}
chart={
selectedStock ? (
<div className="p-0 h-full flex flex-col">
<StockLineChart
symbol={selectedStock.symbol}
candles={selectedStock.candles}
credentials={verifiedCredentials}
latestTick={latestTick}
/>
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
</div>
)
}
orderBook={
<OrderBook
symbol={selectedStock?.symbol}
referencePrice={referencePrice}
currentPrice={currentPrice}
latestTick={latestTick}
recentTicks={recentTradeTicks}
orderBook={orderBook}
isLoading={isOrderBookLoading}
/>
}
orderForm={<OrderForm stock={selectedStock ?? undefined} />}
/>
</div>
);
}

View File

@@ -0,0 +1,257 @@
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useOrder } from "@/features/trade/hooks/useOrder";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
import type {
DashboardOrderSide,
DashboardStockItem,
} from "@/features/trade/types/trade.types";
interface OrderFormProps {
stock?: DashboardStockItem;
}
/**
* @description 대시보드 주문 패널에서 매수/매도 주문을 입력하고 전송합니다.
* @see features/trade/hooks/useOrder.ts placeOrder - 주문 API 호출
* @see features/trade/components/TradeContainer.tsx OrderForm - 우측 주문 패널 렌더링
*/
export function OrderForm({ stock }: OrderFormProps) {
const verifiedCredentials = useKisRuntimeStore(
(state) => state.verifiedCredentials,
);
const { placeOrder, isLoading, error } = useOrder();
// ========== FORM STATE ==========
const [price, setPrice] = useState<string>(stock?.currentPrice.toString() || "");
const [quantity, setQuantity] = useState<string>("");
const [activeTab, setActiveTab] = useState<"buy" | "sell">("buy");
// ========== ORDER HANDLER ==========
const handleOrder = async (side: DashboardOrderSide) => {
if (!stock || !verifiedCredentials) return;
const priceNum = parseInt(price.replace(/,/g, ""), 10);
const qtyNum = parseInt(quantity.replace(/,/g, ""), 10);
if (Number.isNaN(priceNum) || priceNum <= 0) {
alert("가격을 올바르게 입력해 주세요.");
return;
}
if (Number.isNaN(qtyNum) || qtyNum <= 0) {
alert("수량을 올바르게 입력해 주세요.");
return;
}
if (!verifiedCredentials.accountNo) {
alert("계좌번호가 없습니다. 설정에서 계좌번호를 입력해 주세요.");
return;
}
const response = await placeOrder(
{
symbol: stock.symbol,
side,
orderType: "limit",
price: priceNum,
quantity: qtyNum,
accountNo: verifiedCredentials.accountNo,
accountProductCode: "01",
},
verifiedCredentials,
);
if (response?.orderNo) {
alert(`주문 전송 완료: ${response.orderNo}`);
setQuantity("");
}
};
const totalPrice =
parseInt(price.replace(/,/g, "") || "0", 10) *
parseInt(quantity.replace(/,/g, "") || "0", 10);
const setPercent = (pct: string) => {
// TODO: 계좌 잔고 연동 시 퍼센트 자동 계산으로 교체
console.log("Percent clicked:", pct);
};
const isMarketDataAvailable = Boolean(stock);
return (
<div className="h-full border-l border-border bg-background p-3 dark:border-brand-800/45 dark:bg-brand-950/55 sm:p-4">
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as "buy" | "sell")}
className="flex h-full w-full flex-col"
>
{/* ========== ORDER SIDE TABS ========== */}
<TabsList className="mb-3 grid h-10 w-full grid-cols-2 gap-1 border border-brand-200/70 bg-muted/35 p-1 dark:border-brand-700/50 dark:bg-brand-900/28 sm:mb-4">
<TabsTrigger
value="buy"
className="!h-full rounded-md border border-transparent text-foreground/75 transition-colors dark:text-brand-100/75 data-[state=active]:border-red-400/60 data-[state=active]:bg-red-600 data-[state=active]:text-white data-[state=active]:shadow-[0_0_0_1px_rgba(248,113,113,0.45)]"
>
</TabsTrigger>
<TabsTrigger
value="sell"
className="!h-full rounded-md border border-transparent text-foreground/75 transition-colors dark:text-brand-100/75 data-[state=active]:border-blue-400/65 data-[state=active]:bg-blue-600 data-[state=active]:text-white data-[state=active]:shadow-[0_0_0_1px_rgba(96,165,250,0.45)]"
>
</TabsTrigger>
</TabsList>
{/* ========== BUY TAB ========== */}
<TabsContent
value="buy"
className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
>
<OrderInputs
type="buy"
price={price}
setPrice={setPrice}
quantity={quantity}
setQuantity={setQuantity}
totalPrice={totalPrice}
disabled={!isMarketDataAvailable}
hasError={Boolean(error)}
errorMessage={error}
/>
<PercentButtons onSelect={setPercent} />
<Button
className="mt-auto h-11 w-full bg-red-600 text-base text-white shadow-sm ring-1 ring-red-300/35 hover:bg-red-700 dark:bg-red-500 dark:ring-red-300/45 dark:hover:bg-red-400 sm:h-12 sm:text-lg"
disabled={isLoading || !isMarketDataAvailable}
onClick={() => handleOrder("buy")}
>
{isLoading ? <Loader2 className="mr-2 animate-spin" /> : "매수하기"}
</Button>
</TabsContent>
{/* ========== SELL TAB ========== */}
<TabsContent
value="sell"
className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
>
<OrderInputs
type="sell"
price={price}
setPrice={setPrice}
quantity={quantity}
setQuantity={setQuantity}
totalPrice={totalPrice}
disabled={!isMarketDataAvailable}
hasError={Boolean(error)}
errorMessage={error}
/>
<PercentButtons onSelect={setPercent} />
<Button
className="mt-auto h-11 w-full bg-blue-600 text-base text-white shadow-sm ring-1 ring-blue-300/35 hover:bg-blue-700 dark:bg-blue-500 dark:ring-blue-300/45 dark:hover:bg-blue-400 sm:h-12 sm:text-lg"
disabled={isLoading || !isMarketDataAvailable}
onClick={() => handleOrder("sell")}
>
{isLoading ? <Loader2 className="mr-2 animate-spin" /> : "매도하기"}
</Button>
</TabsContent>
</Tabs>
</div>
);
}
/**
* @description 주문 입력 영역(가격/수량/총액)을 렌더링합니다.
* @see features/trade/components/order/OrderForm.tsx OrderForm - 매수/매도 탭에서 공용 호출
*/
function OrderInputs({
type,
price,
setPrice,
quantity,
setQuantity,
totalPrice,
disabled,
hasError,
errorMessage,
}: {
type: "buy" | "sell";
price: string;
setPrice: (v: string) => void;
quantity: string;
setQuantity: (v: string) => void;
totalPrice: number;
disabled: boolean;
hasError: boolean;
errorMessage: string | null;
}) {
return (
<div className="space-y-3 sm:space-y-4">
<div className="flex justify-between text-xs text-muted-foreground">
<span></span>
<span>- {type === "buy" ? "KRW" : "주"}</span>
</div>
{hasError && (
<div className="rounded bg-destructive/10 p-2 text-xs text-destructive break-keep">
{errorMessage}
</div>
)}
<div className="grid grid-cols-4 items-center gap-2">
<span className="text-xs font-medium sm:text-sm">
{type === "buy" ? "매수가격" : "매도가격"}
</span>
<Input
className="col-span-3 text-right font-mono dark:border-brand-700/55 dark:bg-black/25 dark:text-brand-100"
placeholder="0"
value={price}
onChange={(e) => setPrice(e.target.value)}
disabled={disabled}
/>
</div>
<div className="grid grid-cols-4 items-center gap-2">
<span className="text-xs font-medium sm:text-sm"></span>
<Input
className="col-span-3 text-right font-mono dark:border-brand-700/55 dark:bg-black/25 dark:text-brand-100"
placeholder="0"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
disabled={disabled}
/>
</div>
<div className="grid grid-cols-4 items-center gap-2">
<span className="text-xs font-medium sm:text-sm"></span>
<Input
className="col-span-3 bg-muted/50 text-right font-mono dark:border-brand-700/55 dark:bg-black/20 dark:text-brand-100"
value={totalPrice.toLocaleString()}
readOnly
disabled={disabled}
/>
</div>
</div>
);
}
/**
* @description 주문 비율(10/25/50/100%) 단축 버튼을 표시합니다.
* @see features/trade/components/order/OrderForm.tsx setPercent - 버튼 클릭 이벤트 처리
*/
function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
return (
<div className="mt-2 grid grid-cols-4 gap-2">
{["10%", "25%", "50%", "100%"].map((pct) => (
<Button
key={pct}
variant="outline"
size="sm"
className="text-xs"
onClick={() => onSelect(pct)}
>
{pct}
</Button>
))}
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { AnimatePresence, motion } from "framer-motion";
interface AnimatedQuantityProps {
value: number;
format?: (val: number) => string;
className?: string;
/** 값 변동 시 배경 깜빡임 */
useColor?: boolean;
/** 정렬 방향 (ask: 우측 정렬/왼쪽으로 확장, bid: 좌측 정렬/오른쪽으로 확장) */
side?: "ask" | "bid";
}
/**
* 실시간 수량 표시 — 값이 변할 때 ±diff를 인라인으로 보여줍니다.
*/
export function AnimatedQuantity({
value,
format = (v) => v.toLocaleString(),
className,
useColor = false,
side = "bid",
}: AnimatedQuantityProps) {
const prevRef = useRef(value);
const [diff, setDiff] = useState<number | null>(null);
const [flash, setFlash] = useState<"up" | "down" | null>(null);
useEffect(() => {
if (prevRef.current === value) return;
const delta = value - prevRef.current;
prevRef.current = value;
if (delta === 0) return;
setDiff(delta);
setFlash(delta > 0 ? "up" : "down");
const timer = setTimeout(() => {
setDiff(null);
setFlash(null);
}, 1200);
return () => clearTimeout(timer);
}, [value]);
return (
<span
className={cn(
"relative inline-flex items-center gap-1 tabular-nums",
className,
)}
>
{/* 배경 깜빡임 */}
<AnimatePresence>
{useColor && flash && (
<motion.span
initial={{ opacity: 0.5 }}
animate={{ opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1 }}
className={cn(
"absolute inset-0 z-0 rounded-sm",
flash === "up" ? "bg-red-200/50" : "bg-blue-200/50",
)}
/>
)}
</AnimatePresence>
{/* 매도(Ask)일 경우 Diff가 먼저 와야 텍스트가 우측 정렬된 상태에서 흔들리지 않음 */}
{side === "ask" && <DiffChange diff={diff} />}
{/* 수량 값 */}
<span className="relative z-10">{format(value)}</span>
{/* 매수(Bid)일 경우 Diff가 뒤에 와야 텍스트가 좌측 정렬된 상태에서 흔들리지 않음 */}
{side !== "ask" && <DiffChange diff={diff} />}
</span>
);
}
function DiffChange({ diff }: { diff: number | null }) {
return (
<AnimatePresence>
{diff != null && diff !== 0 && (
<motion.span
initial={{ opacity: 1, scale: 1 }}
animate={{ opacity: 0, scale: 0.85 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.2, ease: "easeOut" }}
className={cn(
"relative z-20 whitespace-nowrap text-[9px] font-bold leading-none tabular-nums",
diff > 0 ? "text-red-500" : "text-blue-600 dark:text-blue-400",
)}
>
{diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()}
</motion.span>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,658 @@
import { useMemo } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
} from "@/features/trade/types/trade.types";
import { cn } from "@/lib/utils";
import { AnimatedQuantity } from "./AnimatedQuantity";
// ─── 타입 ───────────────────────────────────────────────
interface OrderBookProps {
symbol?: string;
referencePrice?: number;
currentPrice?: number;
latestTick: DashboardRealtimeTradeTick | null;
recentTicks: DashboardRealtimeTradeTick[];
orderBook: DashboardStockOrderBookResponse | null;
isLoading?: boolean;
}
interface BookRow {
price: number;
size: number;
changePercent: number | null;
isHighlighted: boolean;
}
// ─── 유틸리티 함수 ──────────────────────────────────────
/** 천단위 구분 포맷 */
function fmt(v: number) {
return Number.isFinite(v) ? v.toLocaleString("ko-KR") : "0";
}
/** 부호 포함 퍼센트 */
function fmtPct(v: number) {
return `${v > 0 ? "+" : ""}${v.toFixed(2)}%`;
}
/** 등락률 계산 */
function pctChange(price: number, base: number) {
return base > 0 ? ((price - base) / base) * 100 : 0;
}
/** 체결 시각 포맷 */
function fmtTime(hms: string) {
if (!hms || hms.length !== 6) return "--:--:--";
return `${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}`;
}
/**
* @description 체결 틱 데이터에서 체결 주도 방향(매수/매도/중립)을 계산합니다.
* @see features/trade/components/orderbook/OrderBook.tsx TradeTape 체결량 글자색 결정에 사용합니다.
*/
function resolveTickExecutionSide(
tick: DashboardRealtimeTradeTick,
olderTick?: DashboardRealtimeTradeTick,
) {
// 실시간 체결구분 코드(CNTG_CLS_CODE) 우선 해석
const executionClassCode = (tick.executionClassCode ?? "").trim();
if (executionClassCode === "1" || executionClassCode === "2") {
return "buy" as const;
}
if (executionClassCode === "4" || executionClassCode === "5") {
return "sell" as const;
}
// 누적 건수 기반 데이터는 절대값이 아니라 "증분"으로 판단해야 편향을 줄일 수 있습니다.
if (olderTick) {
const netBuyDelta =
tick.netBuyExecutionCount - olderTick.netBuyExecutionCount;
if (netBuyDelta > 0) return "buy" as const;
if (netBuyDelta < 0) return "sell" as const;
const buyCountDelta = tick.buyExecutionCount - olderTick.buyExecutionCount;
const sellCountDelta =
tick.sellExecutionCount - olderTick.sellExecutionCount;
if (buyCountDelta > sellCountDelta) return "buy" as const;
if (buyCountDelta < sellCountDelta) return "sell" as const;
}
if (tick.askPrice1 > 0 && tick.bidPrice1 > 0) {
if (tick.price >= tick.askPrice1 && tick.price > tick.bidPrice1) {
return "buy" as const;
}
if (tick.price <= tick.bidPrice1 && tick.price < tick.askPrice1) {
return "sell" as const;
}
}
if (tick.tradeStrength > 100) return "buy" as const;
if (tick.tradeStrength < 100) return "sell" as const;
return "neutral" as const;
}
// ─── 메인 컴포넌트 ──────────────────────────────────────
/**
* 호가창 — 실시간 매도·매수 10호가와 체결 목록을 표시합니다.
*/
export function OrderBook({
symbol,
referencePrice,
currentPrice,
latestTick,
recentTicks,
orderBook,
isLoading,
}: OrderBookProps) {
const levels = useMemo(() => orderBook?.levels ?? [], [orderBook]);
// 체결가: tick에서 우선, 없으면 0
const latestPrice =
latestTick?.price && latestTick.price > 0 ? latestTick.price : 0;
// 등락률 기준가
const basePrice =
(referencePrice ?? 0) > 0
? referencePrice!
: (currentPrice ?? 0) > 0
? currentPrice!
: latestPrice > 0
? latestPrice
: 0;
// 매도호가 (역순: 10호가 → 1호가)
const askRows: BookRow[] = useMemo(
() =>
[...levels].reverse().map((l) => ({
price: l.askPrice,
size: Math.max(l.askSize, 0),
changePercent:
l.askPrice > 0 && basePrice > 0
? pctChange(l.askPrice, basePrice)
: null,
isHighlighted: latestPrice > 0 && l.askPrice === latestPrice,
})),
[levels, basePrice, latestPrice],
);
// 매수호가 (1호가 → 10호가)
const bidRows: BookRow[] = useMemo(
() =>
levels.map((l) => ({
price: l.bidPrice,
size: Math.max(l.bidSize, 0),
changePercent:
l.bidPrice > 0 && basePrice > 0
? pctChange(l.bidPrice, basePrice)
: null,
isHighlighted: latestPrice > 0 && l.bidPrice === latestPrice,
})),
[levels, basePrice, latestPrice],
);
const askMax = Math.max(1, ...askRows.map((r) => r.size));
const bidMax = Math.max(1, ...bidRows.map((r) => r.size));
// 스프레드·수급 불균형
const bestAsk = levels.find((l) => l.askPrice > 0)?.askPrice ?? 0;
const bestBid = levels.find((l) => l.bidPrice > 0)?.bidPrice ?? 0;
const spread = bestAsk > 0 && bestBid > 0 ? bestAsk - bestBid : 0;
const totalAsk = orderBook?.totalAskSize ?? 0;
const totalBid = orderBook?.totalBidSize ?? 0;
const imbalance =
totalAsk + totalBid > 0
? ((totalBid - totalAsk) / (totalAsk + totalBid)) * 100
: 0;
// 체결가 행 중앙 스크롤
// ─── 빈/로딩 상태 ───
if (!symbol) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
.
</div>
);
}
if (isLoading && !orderBook) return <OrderBookSkeleton />;
if (!orderBook) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
.
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-brand-900/10">
<Tabs defaultValue="normal" className="h-full min-h-0">
{/* 탭 헤더 */}
<div className="border-b px-2 pt-2 dark:border-brand-800/45 dark:bg-brand-900/28">
<TabsList variant="line" className="w-full justify-start">
<TabsTrigger value="normal" className="px-3">
</TabsTrigger>
<TabsTrigger value="cumulative" className="px-3">
</TabsTrigger>
<TabsTrigger value="order" className="px-3">
</TabsTrigger>
</TabsList>
</div>
{/* ── 일반호가 탭 ── */}
<TabsContent value="normal" className="min-h-0 flex-1">
<div className="block h-full min-h-0 border-t dark:border-brand-800/45 xl:grid xl:grid-rows-[1fr_190px] xl:overflow-hidden">
<div className="block min-h-0 xl:grid xl:grid-cols-[minmax(0,1fr)_168px] xl:overflow-hidden">
{/* 호가 테이블 */}
<div className="min-h-0 xl:border-r dark:border-brand-800/45">
<BookHeader />
<ScrollArea className="h-[320px] sm:h-[360px] xl:h-[calc(100%-32px)] [&>[data-slot=scroll-area-scrollbar]]:hidden xl:[&>[data-slot=scroll-area-scrollbar]]:flex">
{/* 매도호가 */}
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
{/* 중앙 바: 현재 체결가 */}
<div className="grid h-8 grid-cols-3 items-center border-y-2 border-amber-400 bg-amber-50/80 dark:bg-amber-900/30 xl:h-9">
<div className="px-2 text-right text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
{totalAsk > 0 ? fmt(totalAsk) : ""}
</div>
<div className="flex items-center justify-center gap-1">
<span className="text-xs font-bold tabular-nums">
{latestPrice > 0
? fmt(latestPrice)
: bestAsk > 0
? fmt(bestAsk)
: "-"}
</span>
{latestPrice > 0 && basePrice > 0 && (
<span
className={cn(
"text-[10px] font-medium",
latestPrice >= basePrice
? "text-red-500"
: "text-blue-600 dark:text-blue-400",
)}
>
{fmtPct(pctChange(latestPrice, basePrice))}
</span>
)}
</div>
<div className="px-2 text-left text-[10px] font-medium text-muted-foreground dark:text-brand-100/72">
{totalBid > 0 ? fmt(totalBid) : ""}
</div>
</div>
{/* 매수호가 */}
<BookSideRows rows={bidRows} side="bid" maxSize={bidMax} />
</ScrollArea>
</div>
{/* 우측 요약 패널 */}
<div className="hidden xl:block">
<SummaryPanel
orderBook={orderBook}
latestTick={latestTick}
spread={spread}
imbalance={imbalance}
totalAsk={totalAsk}
totalBid={totalBid}
/>
</div>
</div>
{/* 체결 목록 */}
<div className="hidden xl:block">
<TradeTape ticks={recentTicks} />
</div>
</div>
</TabsContent>
{/* ── 누적호가 탭 ── */}
<TabsContent value="cumulative" className="min-h-0 flex-1">
<ScrollArea className="h-full border-t dark:border-brand-800/45">
<div className="p-3">
<div className="mb-2 grid grid-cols-3 text-[11px] font-medium text-muted-foreground">
<span></span>
<span className="text-center"></span>
<span className="text-right"></span>
</div>
<CumulativeRows asks={askRows} bids={bidRows} />
</div>
</ScrollArea>
</TabsContent>
{/* ── 호가주문 탭 ── */}
<TabsContent value="order" className="min-h-0 flex-1">
<div className="flex h-full items-center justify-center border-t px-4 text-sm text-muted-foreground dark:border-brand-800/45 dark:text-brand-100/75">
.
</div>
</TabsContent>
</Tabs>
</div>
);
}
// ─── 하위 컴포넌트 ──────────────────────────────────────
/** 호가 표 헤더 */
function BookHeader() {
return (
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 text-[11px] font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
<div className="flex items-center justify-end px-2"></div>
<div className="flex items-center justify-center border-x"></div>
<div className="flex items-center justify-start px-2"></div>
</div>
);
}
/** 매도 또는 매수 호가 행 목록 */
function BookSideRows({
rows,
side,
maxSize,
}: {
rows: BookRow[];
side: "ask" | "bid";
maxSize: number;
}) {
const isAsk = side === "ask";
return (
<div
className={cn(
isAsk
? "bg-red-50/20 dark:bg-red-950/18"
: "bg-blue-50/55 dark:bg-blue-950/22",
)}
>
{rows.map((row, i) => {
const ratio =
maxSize > 0 ? Math.min((row.size / maxSize) * 100, 100) : 0;
return (
<div
key={`${side}-${row.price}-${i}`}
className={cn(
"grid h-7 grid-cols-3 border-b border-border/40 text-[11px] xl:h-8 xl:text-xs dark:border-brand-800/35",
row.isHighlighted &&
"ring-1 ring-inset ring-amber-400 bg-amber-100/50 dark:bg-amber-800/30",
)}
>
{/* 매도잔량 (좌측) */}
<div className="relative flex items-center justify-end overflow-hidden px-2">
{isAsk && (
<>
<DepthBar ratio={ratio} side="ask" />
{row.size > 0 ? (
<AnimatedQuantity
value={row.size}
format={fmt}
useColor
side="ask"
className="relative z-10"
/>
) : (
<span className="relative z-10 text-transparent">0</span>
)}
</>
)}
</div>
{/* 호가 (중앙) */}
<div
className={cn(
"flex items-center justify-center border-x font-medium tabular-nums gap-1",
row.isHighlighted &&
"border-x-amber-400 bg-amber-50/70 dark:bg-amber-800/20",
)}
>
<span
className={
isAsk ? "text-red-600" : "text-blue-600 dark:text-blue-400"
}
>
{row.price > 0 ? fmt(row.price) : "-"}
</span>
<span
className={cn(
"text-[10px]",
row.changePercent !== null
? row.changePercent >= 0
? "text-red-500"
: "text-blue-600 dark:text-blue-400"
: "text-muted-foreground",
)}
>
{row.changePercent === null ? "-" : fmtPct(row.changePercent)}
</span>
</div>
{/* 매수잔량 (우측) */}
<div className="relative flex items-center justify-start overflow-hidden px-1">
{!isAsk && (
<>
<DepthBar ratio={ratio} side="bid" />
{row.size > 0 ? (
<AnimatedQuantity
value={row.size}
format={fmt}
useColor
side="bid"
className="relative z-10"
/>
) : (
<span className="relative z-10 text-transparent">0</span>
)}
</>
)}
</div>
</div>
);
})}
</div>
);
}
/** 우측 요약 패널 */
function SummaryPanel({
orderBook,
latestTick,
spread,
imbalance,
totalAsk,
totalBid,
}: {
orderBook: DashboardStockOrderBookResponse;
latestTick: DashboardRealtimeTradeTick | null;
spread: number;
imbalance: number;
totalAsk: number;
totalBid: number;
}) {
return (
<div className="min-w-0 border-l bg-muted/10 p-2 text-[11px] dark:border-brand-800/45 dark:bg-brand-900/30">
<Row
label="실시간"
value={orderBook ? "연결됨" : "끊김"}
tone={orderBook ? "bid" : undefined}
/>
<Row
label="거래량"
value={fmt(latestTick?.tradeVolume ?? orderBook.anticipatedVolume ?? 0)}
/>
<Row
label="누적거래량"
value={fmt(
latestTick?.accumulatedVolume ?? orderBook.accumulatedVolume ?? 0,
)}
/>
<Row
label="체결강도"
value={
latestTick
? `${latestTick.tradeStrength.toFixed(2)}%`
: orderBook.anticipatedChangeRate !== undefined
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
: "-"
}
/>
<Row label="예상체결가" value={fmt(orderBook.anticipatedPrice ?? 0)} />
<Row
label="매도1호가"
value={latestTick ? fmt(latestTick.askPrice1) : "-"}
tone="ask"
/>
<Row
label="매수1호가"
value={latestTick ? fmt(latestTick.bidPrice1) : "-"}
tone="bid"
/>
<Row
label="매수체결"
value={latestTick ? fmt(latestTick.buyExecutionCount) : "-"}
/>
<Row
label="매도체결"
value={latestTick ? fmt(latestTick.sellExecutionCount) : "-"}
/>
<Row
label="순매수체결"
value={latestTick ? fmt(latestTick.netBuyExecutionCount) : "-"}
/>
<Row label="총 매도잔량" value={fmt(totalAsk)} tone="ask" />
<Row label="총 매수잔량" value={fmt(totalBid)} tone="bid" />
<Row label="스프레드" value={fmt(spread)} />
<Row
label="수급 불균형"
value={`${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`}
tone={imbalance >= 0 ? "bid" : "ask"}
/>
</div>
);
}
/** 요약 패널 단일 행 */
function Row({
label,
value,
tone,
}: {
label: string;
value: string;
tone?: "ask" | "bid";
}) {
return (
<div className="mb-1.5 flex items-center justify-between gap-2 rounded border bg-background px-2 py-1 dark:border-brand-800/45 dark:bg-black/20">
<span className="min-w-0 truncate text-muted-foreground dark:text-brand-100/70">
{label}
</span>
<span
className={cn(
"shrink-0 font-medium tabular-nums",
tone === "ask" && "text-red-600",
tone === "bid" && "text-blue-600 dark:text-blue-400",
)}
>
{value}
</span>
</div>
);
}
/** 잔량 깊이 바 */
function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
if (ratio <= 0) return null;
return (
<div
className={cn(
"absolute inset-y-1 z-0 rounded-sm",
side === "ask"
? "right-1 bg-red-200/50 dark:bg-red-800/40"
: "left-1 bg-blue-200/55 dark:bg-blue-500/35",
)}
style={{ width: `${ratio}%` }}
/>
);
}
/** 체결 목록 (Trade Tape) */
function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
return (
<div className="border-t bg-background dark:border-brand-800/45 dark:bg-brand-900/20">
<div className="grid h-7 grid-cols-4 border-b bg-muted/20 px-2 text-[11px] font-medium text-muted-foreground dark:border-brand-800/45 dark:bg-brand-900/35 dark:text-brand-100/78">
<div className="flex items-center"></div>
<div className="flex items-center justify-end"></div>
<div className="flex items-center justify-end"></div>
<div className="flex items-center justify-end"></div>
</div>
<ScrollArea className="h-[162px]">
<div>
{ticks.length === 0 && (
<div className="flex h-[160px] items-center justify-center text-xs text-muted-foreground dark:text-brand-100/70">
.
</div>
)}
{ticks.map((t, i) => {
const olderTick = ticks[i + 1];
const executionSide = resolveTickExecutionSide(t, olderTick);
// UI 흐름: 체결목록 UI -> resolveTickExecutionSide(현재/이전 틱) -> 색상 class 결정 -> 체결량 렌더 반영
const volumeToneClass =
executionSide === "buy"
? "text-red-600"
: executionSide === "sell"
? "text-blue-600 dark:text-blue-400"
: "text-muted-foreground dark:text-brand-100/70";
return (
<div
key={`${t.tickTime}-${t.price}-${i}`}
className="grid h-8 grid-cols-4 border-b border-border/40 px-2 text-xs dark:border-brand-800/35"
>
<div className="flex items-center tabular-nums">
{fmtTime(t.tickTime)}
</div>
<div className="flex items-center justify-end tabular-nums text-red-600">
{fmt(t.price)}
</div>
<div
className={cn(
"flex items-center justify-end tabular-nums",
volumeToneClass,
)}
>
{fmt(t.tradeVolume)}
</div>
<div className="flex items-center justify-end tabular-nums">
{t.tradeStrength.toFixed(2)}%
</div>
</div>
);
})}
</div>
</ScrollArea>
</div>
);
}
/** 누적호가 행 */
function CumulativeRows({ asks, bids }: { asks: BookRow[]; bids: BookRow[] }) {
const rows = useMemo(() => {
const len = Math.max(asks.length, bids.length);
const result: { askAcc: number; bidAcc: number; price: number }[] = [];
for (let i = 0; i < len; i++) {
const prevAsk = result[i - 1]?.askAcc ?? 0;
const prevBid = result[i - 1]?.bidAcc ?? 0;
result.push({
askAcc: prevAsk + (asks[i]?.size ?? 0),
bidAcc: prevBid + (bids[i]?.size ?? 0),
price: asks[i]?.price || bids[i]?.price || 0,
});
}
return result;
}, [asks, bids]);
return (
<div className="space-y-1">
{rows.map((r, i) => (
<div
key={i}
className="grid h-7 grid-cols-3 items-center rounded border bg-background px-2 text-xs dark:border-brand-800/45 dark:bg-black/20"
>
<span className="tabular-nums text-red-600">{fmt(r.askAcc)}</span>
<span className="text-center font-medium tabular-nums">
{fmt(r.price)}
</span>
<span className="text-right tabular-nums text-blue-600 dark:text-blue-400">
{fmt(r.bidAcc)}
</span>
</div>
))}
</div>
);
}
/** 로딩 스켈레톤 */
function OrderBookSkeleton() {
return (
<div className="flex h-full flex-col p-3">
<div className="mb-3 grid grid-cols-3 gap-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2">
{Array.from({ length: 16 }).map((_, i) => (
<Skeleton key={i} className="h-7 w-full" />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import type { FormEvent } from "react";
import { Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface StockSearchFormProps {
keyword: string;
onKeywordChange: (value: string) => void;
onSubmit: (event: FormEvent) => void;
onInputFocus?: () => void;
disabled?: boolean;
isLoading?: boolean;
}
/**
* @description 종목 검색 입력/제출 폼을 렌더링합니다.
* @see features/trade/components/TradeContainer.tsx 검색 패널에서 키워드 입력 이벤트를 전달합니다.
*/
export function StockSearchForm({
keyword,
onKeywordChange,
onSubmit,
onInputFocus,
disabled,
isLoading,
}: StockSearchFormProps) {
const handleClear = () => {
onKeywordChange("");
// 포커스 로직이 필요하다면 상위에서 ref를 전달받거나 여기서 ref를 사용해야 함.
// 현재는 단순 값 초기화만 수행.
};
return (
<form onSubmit={onSubmit} className="flex gap-2">
{/* ========== SEARCH INPUT ========== */}
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground dark:text-brand-100/65" />
<Input
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
onFocus={onInputFocus}
placeholder="종목명 또는 종목코드(6자리)를 입력하세요."
autoComplete="off"
className="pl-9 pr-9 dark:border-brand-700/55 dark:bg-brand-900/28 dark:text-brand-100 dark:placeholder:text-brand-100/55"
/>
{keyword && (
<button
type="button"
onClick={handleClear}
className="absolute right-2.5 top-2.5 text-muted-foreground hover:text-foreground dark:text-brand-100/65 dark:hover:text-brand-100"
tabIndex={-1}
aria-label="검색어 지우기"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* ========== SUBMIT BUTTON ========== */}
<Button type="submit" disabled={disabled || isLoading}>
{isLoading ? "검색 중..." : "검색"}
</Button>
</form>
);
}

View File

@@ -0,0 +1,88 @@
import { Clock3, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { DashboardStockSearchHistoryItem } from "@/features/trade/types/trade.types";
interface StockSearchHistoryProps {
items: DashboardStockSearchHistoryItem[];
selectedSymbol?: string;
onSelect: (item: DashboardStockSearchHistoryItem) => void;
onRemove: (symbol: string) => void;
onClear: () => void;
}
/**
* @description 최근 검색 종목 목록을 보여주고, 재검색/개별삭제/전체삭제를 제공합니다.
* @see features/trade/components/TradeContainer.tsx 검색 패널에서 종목 재선택 UI로 사용합니다.
* @see features/trade/hooks/useStockSearch.ts searchHistory 상태를 화면에 렌더링합니다.
*/
export function StockSearchHistory({
items,
selectedSymbol,
onSelect,
onRemove,
onClear,
}: StockSearchHistoryProps) {
if (items.length === 0) return null;
return (
<section className="rounded-md border border-brand-200/80 bg-brand-50/45 p-2 dark:border-brand-700/50 dark:bg-brand-900/26">
{/* ========== HISTORY HEADER ========== */}
<div className="mb-1.5 flex items-center justify-between gap-2 px-1">
<div className="flex items-center gap-1.5 text-xs font-semibold text-brand-700 dark:text-brand-200">
<Clock3 className="h-3.5 w-3.5" />
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClear}
className="h-7 px-2 text-[11px] text-muted-foreground hover:text-foreground dark:text-brand-100/75 dark:hover:text-brand-50"
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
{/* ========== HISTORY LIST ========== */}
<div className="max-h-36 space-y-1 overflow-y-auto pr-1">
{items.map((item) => {
const isSelected = item.symbol === selectedSymbol;
return (
<div key={item.symbol} className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
onClick={() => onSelect(item)}
className={cn(
"h-8 flex-1 justify-between rounded-md border border-transparent px-2.5",
"text-left hover:bg-white/80 dark:hover:bg-brand-800/35",
isSelected &&
"border-brand-300 bg-white text-brand-700 dark:border-brand-500/55 dark:bg-brand-800/40 dark:text-brand-100",
)}
>
<span className="truncate text-sm font-medium">{item.name}</span>
<span className="ml-2 shrink-0 text-[11px] text-muted-foreground dark:text-brand-100/70">
{item.symbol}
</span>
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onRemove(item.symbol)}
aria-label={`${item.name} 히스토리 삭제`}
className="h-8 w-8 shrink-0 text-muted-foreground hover:bg-white/80 hover:text-foreground dark:text-brand-100/70 dark:hover:bg-brand-800/35 dark:hover:text-brand-50"
>
<X className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,47 @@
import { Button } from "@/components/ui/button";
// import { Activity, TrendingDown, TrendingUp } from "lucide-react";
import { cn } from "@/lib/utils";
import type { DashboardStockSearchItem } from "@/features/trade/types/trade.types";
interface StockSearchResultsProps {
items: DashboardStockSearchItem[];
onSelect: (item: DashboardStockSearchItem) => void;
selectedSymbol?: string;
}
export function StockSearchResults({
items,
onSelect,
selectedSymbol,
}: StockSearchResultsProps) {
if (items.length === 0) return null;
return (
<div className="flex flex-col p-2">
{items.map((item) => {
const isSelected = item.symbol === selectedSymbol;
return (
<Button
key={item.symbol}
variant="outline"
className={cn(
"h-auto w-full flex-col items-start gap-1 p-3 text-left",
isSelected && "border-brand-500 bg-brand-50 hover:bg-brand-100",
)}
onClick={() => onSelect(item)}
>
<div className="flex w-full items-center justify-between gap-2">
<span className="font-semibold truncate">{item.name}</span>
<span className="text-xs text-muted-foreground shrink-0">
{item.symbol}
</span>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{item.market}
</div>
</Button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,101 @@
import type { FormEvent, KeyboardEvent, FocusEvent, MutableRefObject } from "react";
import { StockSearchForm } from "@/features/trade/components/search/StockSearchForm";
import { StockSearchHistory } from "@/features/trade/components/search/StockSearchHistory";
import { StockSearchResults } from "@/features/trade/components/search/StockSearchResults";
import type {
DashboardStockSearchHistoryItem,
DashboardStockSearchItem,
} from "@/features/trade/types/trade.types";
interface TradeSearchSectionProps {
canSearch: boolean;
isSearchPanelOpen: boolean;
isSearching: boolean;
keyword: string;
selectedSymbol?: string;
searchResults: DashboardStockSearchItem[];
searchHistory: DashboardStockSearchHistoryItem[];
searchShellRef: MutableRefObject<HTMLDivElement | null>;
onKeywordChange: (value: string) => void;
onSearchSubmit: (event: FormEvent) => void;
onSearchFocus: () => void;
onSearchShellBlur: (event: FocusEvent<HTMLDivElement>) => void;
onSearchShellKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
onSelectStock: (item: DashboardStockSearchItem) => void;
onRemoveHistory: (symbol: string) => void;
onClearHistory: () => void;
}
/**
* @description 트레이드 화면 상단의 검색 입력/결과/히스토리 드롭다운 영역을 렌더링합니다.
* @see features/trade/components/TradeContainer.tsx TradeContainer에서 검색 섹션을 분리해 렌더 복잡도를 줄입니다.
* @see features/trade/hooks/useTradeSearchPanel.ts 패널 열림/닫힘 및 포커스 핸들러를 전달받습니다.
*/
export function TradeSearchSection({
canSearch,
isSearchPanelOpen,
isSearching,
keyword,
selectedSymbol,
searchResults,
searchHistory,
searchShellRef,
onKeywordChange,
onSearchSubmit,
onSearchFocus,
onSearchShellBlur,
onSearchShellKeyDown,
onSelectStock,
onRemoveHistory,
onClearHistory,
}: TradeSearchSectionProps) {
return (
<div className="z-30 flex-none border-b bg-background/95 p-4 backdrop-blur-sm dark:border-brand-800/45 dark:bg-brand-900/22">
{/* ========== SEARCH SHELL ========== */}
<div
ref={searchShellRef}
onBlurCapture={onSearchShellBlur}
onKeyDownCapture={onSearchShellKeyDown}
className="relative mx-auto max-w-2xl"
>
<StockSearchForm
keyword={keyword}
onKeywordChange={onKeywordChange}
onSubmit={onSearchSubmit}
onInputFocus={onSearchFocus}
disabled={!canSearch}
isLoading={isSearching}
/>
{/* ========== SEARCH DROPDOWN ========== */}
{isSearchPanelOpen && canSearch && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-80 overflow-x-hidden overflow-y-auto rounded-md border bg-background shadow-lg dark:border-brand-800/45 dark:bg-brand-950/95">
{searchResults.length > 0 ? (
<StockSearchResults
items={searchResults}
onSelect={onSelectStock}
selectedSymbol={selectedSymbol}
/>
) : keyword.trim() ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
{isSearching ? "검색 중..." : "검색 결과가 없습니다."}
</div>
) : searchHistory.length > 0 ? (
<StockSearchHistory
items={searchHistory}
onSelect={onSelectStock}
onRemove={onRemoveHistory}
onClear={onClearHistory}
selectedSymbol={selectedSymbol}
/>
) : (
<div className="px-3 py-2 text-sm text-muted-foreground">
.
</div>
)}
</div>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
import rawStocks from "@/features/trade/data/korean-stocks.json";
import type { KoreanStockIndexItem } from "@/features/trade/types/trade.types";
/**
* 국내주식 검색 인덱스(KOSPI + KOSDAQ)
* - 파일 원본: korean-stocks.json
* - 사용처: /api/kis/domestic/search 라우트의 메모리 검색
* @see app/api/kis/domestic/search/route.ts 종목명/종목코드 검색에 사용합니다.
*/
export const KOREAN_STOCK_INDEX = rawStocks as KoreanStockIndexItem[];

View File

@@ -0,0 +1,126 @@
/**
* @file features/trade/data/mock-stocks.ts
* @description 대시보드 1단계 UI 검증용 목업 종목 데이터
* @remarks
* - 한국투자증권 API 연동 전까지 화면 동작 검증에 사용합니다.
* - 2단계 이후 실제 화면은 app/api/kis/* 응답을 사용합니다.
* - 현재는 레거시/비교용 샘플 데이터로만 남겨둔 상태입니다.
*/
import type { DashboardStockItem } from "@/features/trade/types/trade.types";
/**
* 대시보드 목업 종목 목록
* @see app/(main)/dashboard/page.tsx DashboardPage가 DashboardMain을 통해 이 데이터를 조회합니다.
* @see features/trade/components/dashboard-main.tsx 검색/차트/지표 카드의 기본 데이터 소스입니다.
*/
export const MOCK_STOCKS: DashboardStockItem[] = [
{
symbol: "005930",
name: "삼성전자",
market: "KOSPI",
currentPrice: 78500,
change: 1200,
changeRate: 1.55,
open: 77300,
high: 78900,
low: 77000,
prevClose: 77300,
volume: 15234012,
candles: [
{ time: "09:00", price: 74400 },
{ time: "09:10", price: 74650 },
{ time: "09:20", price: 75100 },
{ time: "09:30", price: 74950 },
{ time: "09:40", price: 75300 },
{ time: "09:50", price: 75600 },
{ time: "10:00", price: 75400 },
{ time: "10:10", price: 75850 },
{ time: "10:20", price: 76100 },
{ time: "10:30", price: 75950 },
{ time: "10:40", price: 76350 },
{ time: "10:50", price: 76700 },
{ time: "11:00", price: 76900 },
{ time: "11:10", price: 77250 },
{ time: "11:20", price: 77100 },
{ time: "11:30", price: 77400 },
{ time: "11:40", price: 77700 },
{ time: "11:50", price: 78150 },
{ time: "12:00", price: 77900 },
{ time: "12:10", price: 78300 },
{ time: "12:20", price: 78500 },
],
},
{
symbol: "000660",
name: "SK하이닉스",
market: "KOSPI",
currentPrice: 214500,
change: -1500,
changeRate: -0.69,
open: 216000,
high: 218000,
low: 213000,
prevClose: 216000,
volume: 3210450,
candles: [
{ time: "09:00", price: 221000 },
{ time: "09:10", price: 220400 },
{ time: "09:20", price: 219900 },
{ time: "09:30", price: 220200 },
{ time: "09:40", price: 219300 },
{ time: "09:50", price: 218500 },
{ time: "10:00", price: 217900 },
{ time: "10:10", price: 218300 },
{ time: "10:20", price: 217600 },
{ time: "10:30", price: 216900 },
{ time: "10:40", price: 216500 },
{ time: "10:50", price: 216800 },
{ time: "11:00", price: 215900 },
{ time: "11:10", price: 215300 },
{ time: "11:20", price: 214800 },
{ time: "11:30", price: 215100 },
{ time: "11:40", price: 214200 },
{ time: "11:50", price: 214700 },
{ time: "12:00", price: 214300 },
{ time: "12:10", price: 214600 },
{ time: "12:20", price: 214500 },
],
},
{
symbol: "035420",
name: "NAVER",
market: "KOSPI",
currentPrice: 197800,
change: 2200,
changeRate: 1.12,
open: 195500,
high: 198600,
low: 194900,
prevClose: 195600,
volume: 1904123,
candles: [
{ time: "09:00", price: 191800 },
{ time: "09:10", price: 192400 },
{ time: "09:20", price: 193000 },
{ time: "09:30", price: 192700 },
{ time: "09:40", price: 193600 },
{ time: "09:50", price: 194200 },
{ time: "10:00", price: 194000 },
{ time: "10:10", price: 194900 },
{ time: "10:20", price: 195100 },
{ time: "10:30", price: 194700 },
{ time: "10:40", price: 195800 },
{ time: "10:50", price: 196400 },
{ time: "11:00", price: 196100 },
{ time: "11:10", price: 196900 },
{ time: "11:20", price: 197200 },
{ time: "11:30", price: 197000 },
{ time: "11:40", price: 197600 },
{ time: "11:50", price: 198000 },
{ time: "12:00", price: 197400 },
{ time: "12:10", price: 198300 },
{ time: "12:20", price: 197800 },
],
},
];

View File

@@ -0,0 +1,53 @@
import { useMemo } from "react";
import type {
DashboardRealtimeTradeTick,
DashboardStockItem,
DashboardStockOrderBookResponse,
} from "@/features/trade/types/trade.types";
interface UseCurrentPriceParams {
stock?: DashboardStockItem | null;
latestTick: DashboardRealtimeTradeTick | null;
orderBook: DashboardStockOrderBookResponse | null;
}
export function useCurrentPrice({
stock,
latestTick,
orderBook,
}: UseCurrentPriceParams) {
return useMemo(() => {
let currentPrice = stock?.currentPrice ?? 0;
let change = stock?.change ?? 0;
let changeRate = stock?.changeRate ?? 0;
const prevClose = stock?.prevClose ?? 0;
// 1. Priority: Realtime Tick (Trade WS)
if (latestTick?.price && latestTick.price > 0) {
currentPrice = latestTick.price;
change = latestTick.change;
changeRate = latestTick.changeRate;
}
// 2. Fallback: OrderBook Best Ask (Proxy for current price if no tick)
else if (
orderBook?.levels[0]?.askPrice &&
orderBook.levels[0].askPrice > 0
) {
const askPrice = orderBook.levels[0].askPrice;
currentPrice = askPrice;
// Recalculate change/rate based on prevClose
if (prevClose > 0) {
change = currentPrice - prevClose;
changeRate = (change / prevClose) * 100;
}
}
return {
currentPrice,
change,
changeRate,
prevClose,
};
}, [stock, latestTick, orderBook]);
}

Some files were not shown because too many files have changed in this diff Show More