From 89b13ac3085325753ba576bc3bbbc819bbcfd219 Mon Sep 17 00:00:00 2001 From: "jihoon87.lee" Date: Tue, 10 Feb 2026 11:16:39 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=A4=91=EA=B0=84=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agent/rules/builder-rule.md | 175 +++ .agent/rules/code-analysis-rule.md | 313 ++++++ .agent/rules/master-integration.md | 341 ++++++ .agent/rules/refactoring-rule.md | 94 ++ .tmp/kis-token-cache.json | 1 + app/(home)/page.tsx | 317 +++--- app/(main)/dashboard/page.tsx | 8 +- app/api/kis/domestic/chart/route.ts | 98 ++ app/api/kis/domestic/order-cash/route.ts | 104 ++ app/api/kis/domestic/orderbook/route.ts | 106 ++ app/api/kis/domestic/overview/route.ts | 2 +- app/layout.tsx | 8 + components/theme-toggle.tsx | 24 +- components/ui/badge.tsx | 48 + components/ui/scroll-area.tsx | 58 + components/ui/separator.tsx | 2 +- components/ui/shader-background.tsx | 244 +++++ components/ui/skeleton.tsx | 13 + components/ui/tabs.tsx | 91 ++ features/auth/components/session-timer.tsx | 19 +- features/dashboard/apis/kis-auth.api.ts | 82 ++ features/dashboard/apis/kis-stock.api.ts | 179 ++++ .../components/DashboardContainer.tsx | 261 +++++ .../dashboard/components/auth/KisAuthForm.tsx | 230 ++++ .../components/chart/StockLineChart.tsx | 636 +++++++++++ .../dashboard/components/dashboard-main.tsx | 999 ------------------ .../components/details/StockOverviewCard.tsx | 144 +++ .../components/details/StockPriceBadge.tsx | 48 + .../components/header/StockHeader.tsx | 71 ++ .../components/layout/DashboardLayout.tsx | 54 + .../dashboard/components/order/OrderForm.tsx | 249 +++++ .../components/orderbook/AnimatedQuantity.tsx | 80 ++ .../components/orderbook/OrderBook.tsx | 763 +++++++++++++ .../components/search/StockSearchForm.tsx | 37 + .../components/search/StockSearchResults.tsx | 47 + .../hooks/useKisOrderbookWebSocket.ts | 184 ++++ .../dashboard/hooks/useKisTradeWebSocket.ts | 283 +++++ features/dashboard/hooks/useOrder.ts | 61 ++ features/dashboard/hooks/useOrderBook.ts | 115 ++ features/dashboard/hooks/useStockOverview.ts | 118 +++ features/dashboard/hooks/useStockSearch.ts | 91 ++ .../dashboard/store/use-kis-runtime-store.ts | 95 +- features/dashboard/types/dashboard.types.ts | 108 +- .../dashboard/utils/kis-realtime.utils.ts | 269 +++++ features/layout/components/header.tsx | 124 ++- features/layout/components/user-menu.tsx | 58 +- lib/kis/client.ts | 67 +- lib/kis/domestic.ts | 557 +++++++++- lib/kis/token.ts | 69 +- lib/kis/trade.ts | 80 ++ package-lock.json | 16 + package.json | 1 + 52 files changed, 6955 insertions(+), 1287 deletions(-) create mode 100644 .agent/rules/builder-rule.md create mode 100644 .agent/rules/code-analysis-rule.md create mode 100644 .agent/rules/master-integration.md create mode 100644 .agent/rules/refactoring-rule.md create mode 100644 .tmp/kis-token-cache.json create mode 100644 app/api/kis/domestic/chart/route.ts create mode 100644 app/api/kis/domestic/order-cash/route.ts create mode 100644 app/api/kis/domestic/orderbook/route.ts create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 components/ui/shader-background.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 features/dashboard/apis/kis-auth.api.ts create mode 100644 features/dashboard/apis/kis-stock.api.ts create mode 100644 features/dashboard/components/DashboardContainer.tsx create mode 100644 features/dashboard/components/auth/KisAuthForm.tsx create mode 100644 features/dashboard/components/chart/StockLineChart.tsx delete mode 100644 features/dashboard/components/dashboard-main.tsx create mode 100644 features/dashboard/components/details/StockOverviewCard.tsx create mode 100644 features/dashboard/components/details/StockPriceBadge.tsx create mode 100644 features/dashboard/components/header/StockHeader.tsx create mode 100644 features/dashboard/components/layout/DashboardLayout.tsx create mode 100644 features/dashboard/components/order/OrderForm.tsx create mode 100644 features/dashboard/components/orderbook/AnimatedQuantity.tsx create mode 100644 features/dashboard/components/orderbook/OrderBook.tsx create mode 100644 features/dashboard/components/search/StockSearchForm.tsx create mode 100644 features/dashboard/components/search/StockSearchResults.tsx create mode 100644 features/dashboard/hooks/useKisOrderbookWebSocket.ts create mode 100644 features/dashboard/hooks/useKisTradeWebSocket.ts create mode 100644 features/dashboard/hooks/useOrder.ts create mode 100644 features/dashboard/hooks/useOrderBook.ts create mode 100644 features/dashboard/hooks/useStockOverview.ts create mode 100644 features/dashboard/hooks/useStockSearch.ts create mode 100644 features/dashboard/utils/kis-realtime.utils.ts create mode 100644 lib/kis/trade.ts diff --git a/.agent/rules/builder-rule.md b/.agent/rules/builder-rule.md new file mode 100644 index 0000000..c980074 --- /dev/null +++ b/.agent/rules/builder-rule.md @@ -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//` 를 따른다. +- 내부 구성 예시: + - `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//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` 타입 사용 금지 \ No newline at end of file diff --git a/.agent/rules/code-analysis-rule.md b/.agent/rules/code-analysis-rule.md new file mode 100644 index 0000000..155a173 --- /dev/null +++ b/.agent/rules/code-analysis-rule.md @@ -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 ; // ← 실제 로직이 담긴 컴포넌트 +} +``` + +--- + +### 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(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 + +// 📌 제너릭 사용: 어떤 타입이든 data로 받을 수 있음 +interface ApiResponse { + data: T; + message: string; +} + +export const personnelApi = { + getList: async (params): Promise> => { + const response = await axiosInstance.get('/api/v1/personnel', { params }); + return response.data; + }, + + // 📌 Omit: T에서 K 키 제외 (id, createdAt은 서버 생성) + create: async (data: Omit) => { + return await axiosInstance.post('/api/v1/personnel', data); + }, + + // 📌 Partial: 모든 속성을 선택적으로 (부분 수정용) + update: async (id: string, data: Partial) => { + 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(arr: T[]): T | undefined { + return arr[0]; +} +const firstNumber = getFirst([1, 2, 3]); // number | undefined +``` + +**주요 유틸리티 타입**: +```tsx +interface Person { id: string; name: string; age: number; createdAt: Date; } + +// Partial - 모든 속성을 선택적으로 (부분 업데이트용) +type PartialPerson = Partial; + +// Pick - 특정 속성만 선택 +type PersonName = Pick; + +// Omit - 특정 속성 제외 (생성 시 서버 자동 생성 필드 제외) +type PersonWithoutId = Omit; + +// Record - 키-값 쌍의 객체 타입 +type Filters = Record; +``` + +**타입 가드 (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/ # 공통 스토어 +``` \ No newline at end of file diff --git a/.agent/rules/master-integration.md b/.agent/rules/master-integration.md new file mode 100644 index 0000000..e4c1d21 --- /dev/null +++ b/.agent/rules/master-integration.md @@ -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`
`vercel-react-best-practices` | `sequential-thinking` (복잡한 로직)
`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` (의존성 분석)
`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개 룰 diff --git a/.agent/rules/refactoring-rule.md b/.agent/rules/refactoring-rule.md new file mode 100644 index 0000000..11eb577 --- /dev/null +++ b/.agent/rules/refactoring-rule.md @@ -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 +/ +├── apis/ +│ ├── apiError.ts +│ ├── .api.ts # API 호출 로직 +│ ├── Form.adapter.ts # Form <-> API 변환 +│ └── List.adapter.ts # List <-> API 변환 +├── hooks/ +│ ├── queryKeys.ts # Query Key Factory +│ ├── useList.ts # 목록 조회 Hooks +│ ├── useMutations.ts # CUD Hooks +│ └── useForm.ts # Form Logic Hooks +├── types/ +│ ├── api.types.ts # 공통 API 응답 규격 +│ ├── .types.ts # 도메인 Entity +│ └── selectOption.types.ts # 공통 Select Option +├── stores/ +│ └── Store.ts # Zustand Store +├── components/ +│ ├── Container.tsx # 메인 컨테이너 +│ └── Modal.tsx # 모달 컴포넌트 +├── utils/ # (Optional) +│ └── Utils.ts # 순수 헬퍼 함수 +└── constants/ # (Optional) + └── .constants.ts # 상수 (components 내부에 둬도 무방) +``` + +--- + +## ⚠️ 규칙 (Rules) + +1. **로직 변경 금지**: 오직 파일 위치와 구조만 변경하며, 비즈니스 로직은 건드리지 않는다. +2. **Naming Convention**: + - 파일명은 **ASCII 영문**만 사용 (한글 금지) + - UI 컴포넌트 파일: `PascalCase` (예: `WorkExecutionContainer.tsx`) + - Hooks 및 일반 파일: `camelCase` (예: `useWorkExecutionList.ts`) +3. **Clean Import**: import 시 불필요한 별칭(alias)보다는 명확한 상대/절대 경로를 사용한다. \ No newline at end of file diff --git a/.tmp/kis-token-cache.json b/.tmp/kis-token-cache.json new file mode 100644 index 0000000..7d4a3c0 --- /dev/null +++ b/.tmp/kis-token-cache.json @@ -0,0 +1 @@ +{"real:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImFkMGFjN2Y3LWY5YTYtNDRlNy1iOGRjLWU0ZjRhY2Q0YmQ2NyIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDcwNzkzMiwiaWF0IjoxNzcwNjIxNTMyLCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.P1_T4XoIxPDyNIxS3rx73IqlNLpZRmKKXOtan74A7D19On9JlsIQIpTY8bVGPFfRxFkC0vfCZ1qDX-xpxGi6SA","expiresAt":1770707932000}} \ No newline at end of file diff --git a/app/(home)/page.tsx b/app/(home)/page.tsx index 8b5ad6b..947a6e6 100644 --- a/app/(home)/page.tsx +++ b/app/(home)/page.tsx @@ -1,222 +1,163 @@ -/** +/** * @file app/(home)/page.tsx - * @description 서비스 메인 랜딩 페이지 - * @remarks - * - [레이어] Pages (Server Component) - * - [역할] 비로그인 유저 유입, 브랜드 소개, 로그인/대시보드 유도 - * - [구성요소] Header, Hero Section (Spline 3D), Feature Section (Bento Grid) - * - [데이터 흐름] Server Auth Check -> Client Component Props + * @description 서비스 메인 랜딩 페이지(Server Component) */ import Link from "next/link"; -import { Button } from "@/components/ui/button"; -import { createClient } from "@/utils/supabase/server"; +import { ArrowRight, BarChart3, ShieldCheck, Sparkles, 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"; /** - * 메인 페이지 컴포넌트 (비동기 서버 컴포넌트) - * @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(); return ( -
-
+
+
-
- {/* Background Pattern */} -
+
+ {/* ========== SHADER BACKGROUND SECTION ========== */} + -
-
- {/* Badge */} -
- - The Future of Trading -
- -

- 투자의 미래를
- - 자동화하세요 + {/* ========== HERO SECTION ========== */} +
+
+
+ + + Shader Background Landing -

-

- AutoTrade는 최첨단 알고리즘을 통해 24시간 암호화폐 시장을 - 분석합니다. -
- 감정 없는 데이터 기반 투자로 안정적인 수익을 경험해보세요. -

+

+ 데이터로 판단하고 +
+ + 자동으로 실행합니다 + +

-
- {user ? ( - - ) : ( - - )} - {!user && ( - - )} +

+ 실시간 시세 확인, 전략 점검, 주문 연결까지 한 화면에서 이어지는 자동매매 환경을 + 제공합니다. 복잡한 설정은 줄이고 실행 속도는 높였습니다. +

+ +
+ {/* [분기 렌더] 로그인 사용자는 대시보드, 비로그인 사용자는 가입/로그인 동선을 노출합니다. */} + {user ? ( + + ) : ( + <> + + + + )} +
- {/* Spline Scene - Centered & Wide */} -
-
- {/* Glow Effect */} -
- - +
+
+

지연 시간 기준

+

Low Latency

+
+
+

모니터링

+

실시간 시세 반영

+
+
+

실행 환경

+

웹 기반 자동매매

- {/* Features Section - Bento Grid */} -
-
-

- 강력한 기능,{" "} - 직관적인 경험 -

-

- 성공적인 투자를 위해 필요한 모든 도구가 준비되어 있습니다. -

+ {/* ========== FEATURE SECTION ========== */} +
+
+ + +
+ +
+ 실시간 데이터 가시화 +
+ + 시세 변화와 거래 흐름을 빠르게 확인할 수 있게 핵심 정보만 선별해 보여줍니다. + +
+ + + +
+ +
+ 전략 실행 속도 최적화 +
+ + 필요한 단계만 남긴 단순한 흐름으로 전략 테스트와 실행 전환 시간을 줄였습니다. + +
+ + + +
+ +
+ 명확한 리스크 관리 +
+ + 자동매매에서 중요한 손실 한도와 조건을 먼저 정의하고 일관되게 적용할 수 있습니다. + +
+
-
- {/* Feature 1 */} -
-
-
- - - -
-
-

실시간 모니터링

-

- 초당 수천 건의 트랜잭션을 실시간으로 분석합니다. -
- 시장 변동성을 놓치지 않고 최적의 진입 시점을 포착하세요. -

-
+ {/* ========== CTA SECTION ========== */} +
+
+
+
+

준비되면 바로 시작하세요

+

+ AutoTrade에서 전략을 실행해 보세요 +

-
-
- - {/* Feature 2 (Tall) */} -
-
-
- - - -
-

알고리즘 트레이딩

-

- 24시간 멈추지 않는 자동 매매 시스템입니다. -

-
- {[ - "추세 추종 전략", - "변동성 돌파", - "AI 예측 모델", - "리스크 관리", - ].map((item) => ( -
-
- {item} -
- ))} -
-
-
-
- - {/* Feature 3 */} -
-
-
- - - -
-
-

스마트 포트폴리오

-

- 목표 수익률 달성 시 자동으로 이익을 실현하고, MDD를 - 최소화하여 -
- 시장이 하락할 때도 당신의 자산을 안전하게 지킵니다. -

-
-
-
+
diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx index 303e605..60fe5b7 100644 --- a/app/(main)/dashboard/page.tsx +++ b/app/(main)/dashboard/page.tsx @@ -5,12 +5,12 @@ import { redirect } from "next/navigation"; import { createClient } from "@/utils/supabase/server"; -import { DashboardMain } from "@/features/dashboard/components/dashboard-main"; +import { DashboardContainer } from "@/features/dashboard/components/DashboardContainer"; /** * 대시보드 페이지 - * @returns DashboardMain UI - * @see features/dashboard/components/dashboard-main.tsx 클라이언트 상호작용(검색/시세/차트)은 해당 컴포넌트가 담당합니다. + * @returns DashboardContainer UI + * @see features/dashboard/components/DashboardContainer.tsx 클라이언트 상호작용(검색/시세/차트)은 해당 컴포넌트가 담당합니다. */ export default async function DashboardPage() { // 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다. @@ -21,5 +21,5 @@ export default async function DashboardPage() { if (!user) redirect("/login"); - return ; + return ; } diff --git a/app/api/kis/domestic/chart/route.ts b/app/api/kis/domestic/chart/route.ts new file mode 100644 index 0000000..a190bef --- /dev/null +++ b/app/api/kis/domestic/chart/route.ts @@ -0,0 +1,98 @@ +import type { + DashboardChartTimeframe, + DashboardStockChartResponse, +} from "@/features/dashboard/types/dashboard.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, + }; +} diff --git a/app/api/kis/domestic/order-cash/route.ts b/app/api/kis/domestic/order-cash/route.ts new file mode 100644 index 0000000..50f97d9 --- /dev/null +++ b/app/api/kis/domestic/order-cash/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from "next/server"; +import { executeOrderCash } from "@/lib/kis/trade"; +import { + DashboardStockCashOrderRequest, + DashboardStockCashOrderResponse, +} from "@/features/dashboard/types/dashboard.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, + }; +} diff --git a/app/api/kis/domestic/orderbook/route.ts b/app/api/kis/domestic/orderbook/route.ts new file mode 100644 index 0000000..104b569 --- /dev/null +++ b/app/api/kis/domestic/orderbook/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + getDomesticOrderBook, + KisDomesticOrderBookOutput, +} from "@/lib/kis/domestic"; +import { DashboardStockOrderBookResponse } from "@/features/dashboard/types/dashboard.types"; +import { + KisCredentialInput, + hasKisConfig, + normalizeTradingEnv, +} from "@/lib/kis/config"; + +/** + * @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 raw = await getDomesticOrderBook(symbol, credentials); + + const levels = Array.from({ length: 10 }, (_, i) => { + const idx = i + 1; + return { + askPrice: readOrderBookNumber(raw, `askp${idx}`), + bidPrice: readOrderBookNumber(raw, `bidp${idx}`), + askSize: readOrderBookNumber(raw, `askp_rsqn${idx}`), + bidSize: readOrderBookNumber(raw, `bidp_rsqn${idx}`), + }; + }); + + const response: DashboardStockOrderBookResponse = { + symbol, + source: "kis", + levels, + totalAskSize: readOrderBookNumber(raw, "total_askp_rsqn"), + totalBidSize: readOrderBookNumber(raw, "total_bidp_rsqn"), + 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, + }; +} + +/** + * @description 호가 응답 필드를 대소문자 모두 허용해 숫자로 읽습니다. + * @see app/api/kis/domestic/orderbook/route.ts GET에서 output/output1 키 차이 방어 로직으로 사용합니다. + */ +function readOrderBookNumber(raw: KisDomesticOrderBookOutput, key: string) { + const record = raw as Record; + const direct = record[key]; + const upper = record[key.toUpperCase()]; + const value = direct ?? upper ?? "0"; + const normalized = + typeof value === "string" + ? value.replaceAll(",", "").trim() + : String(value ?? "0"); + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : 0; +} diff --git a/app/api/kis/domestic/overview/route.ts b/app/api/kis/domestic/overview/route.ts index a608ed7..b658312 100644 --- a/app/api/kis/domestic/overview/route.ts +++ b/app/api/kis/domestic/overview/route.ts @@ -1,4 +1,4 @@ -import { KOREAN_STOCK_INDEX } from "@/features/dashboard/data/korean-stocks"; +import { KOREAN_STOCK_INDEX } from "@/features/dashboard/data/korean-stocks"; import type { DashboardStockOverviewResponse } from "@/features/dashboard/types/dashboard.types"; import type { KisCredentialInput } from "@/lib/kis/config"; import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config"; diff --git a/app/layout.tsx b/app/layout.tsx index 6aa9c2e..40091a3 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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({ @@ -61,6 +62,13 @@ export default function RootLayout({ > {children} + diff --git a/components/theme-toggle.tsx b/components/theme-toggle.tsx index bd9ca0c..9b6cd44 100644 --- a/components/theme-toggle.tsx +++ b/components/theme-toggle.tsx @@ -12,6 +12,7 @@ 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 { @@ -21,23 +22,38 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +interface ThemeToggleProps { + className?: string; + iconClassName?: string; +} + /** * 테마 토글 컴포넌트 * @remarks next-themes의 useTheme 훅 사용 * @returns Dropdown 메뉴 형태의 테마 선택기 */ -export function ThemeToggle() { +export function ThemeToggle({ className, iconClassName }: ThemeToggleProps) { const { setTheme } = useTheme(); return ( {/* ========== 트리거 버튼 ========== */} - diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..beb56ed --- /dev/null +++ b/components/ui/badge.tsx @@ -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 & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/scroll-area.tsx b/components/ui/scroll-area.tsx new file mode 100644 index 0000000..0f873dc --- /dev/null +++ b/components/ui/scroll-area.tsx @@ -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) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx index 275381c..4c24b2a 100644 --- a/components/ui/separator.tsx +++ b/components/ui/separator.tsx @@ -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" diff --git a/components/ui/shader-background.tsx b/components/ui/shader-background.tsx new file mode 100644 index 0000000..df9fa46 --- /dev/null +++ b/components/ui/shader-background.tsx @@ -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(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 ( +