2 Commits

Author SHA1 Message Date
e5a518b211 레이아웃 변경 대시보드 2026-02-10 17:29:57 +09:00
ca01f33d71 대시보드 중간 커밋 2026-02-10 17:16:49 +09:00
16 changed files with 569 additions and 96 deletions

View File

@@ -1 +1 @@
{"real:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImFkMGFjN2Y3LWY5YTYtNDRlNy1iOGRjLWU0ZjRhY2Q0YmQ2NyIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDcwNzkzMiwiaWF0IjoxNzcwNjIxNTMyLCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.P1_T4XoIxPDyNIxS3rx73IqlNLpZRmKKXOtan74A7D19On9JlsIQIpTY8bVGPFfRxFkC0vfCZ1qDX-xpxGi6SA","expiresAt":1770707932000}} {"mock:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImRhNWMyYjU5LTA3YTUtNGVhNy1hY2UyLWZlNTMwZTBjM2ZjNCIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDc5MzIzOCwiaWF0IjoxNzcwNzA2ODM4LCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.vDnx1Vx-LnzaRETwkR5aQUnl7vV3-KlXG5AVlMRrchjdovJzd5n1vL7YfG_146Swrao2Drw8TwnpdK44-aTrhg","expiresAt":1770793238000},"real:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImRhNWMyYjU5LTA3YTUtNGVhNy1hY2UyLWZlNTMwZTBjM2ZjNCIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDc5MzIzOCwiaWF0IjoxNzcwNzA2ODM4LCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.vDnx1Vx-LnzaRETwkR5aQUnl7vV3-KlXG5AVlMRrchjdovJzd5n1vL7YfG_146Swrao2Drw8TwnpdK44-aTrhg","expiresAt":1770793238000}}

View File

@@ -1,5 +1,5 @@
import { Header } from "@/features/layout/components/header"; 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"; import { createClient } from "@/utils/supabase/server";
export default async function MainLayout({ export default async function MainLayout({
@@ -17,8 +17,9 @@ export default async function MainLayout({
<Header user={user} /> <Header user={user} />
<div className="flex flex-1 pt-16"> <div className="flex flex-1 pt-16">
<Sidebar /> <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> </div>
<MobileBottomNav />
</div> </div>
); );
} }

View File

@@ -1,8 +1,10 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store"; import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
import { KisAuthForm } from "@/features/dashboard/components/auth/KisAuthForm"; import { KisAuthForm } from "@/features/dashboard/components/auth/KisAuthForm";
import { StockSearchForm } from "@/features/dashboard/components/search/StockSearchForm"; import { StockSearchForm } from "@/features/dashboard/components/search/StockSearchForm";
@@ -11,6 +13,7 @@ import { useStockSearch } from "@/features/dashboard/hooks/useStockSearch";
import { useOrderBook } from "@/features/dashboard/hooks/useOrderBook"; import { useOrderBook } from "@/features/dashboard/hooks/useOrderBook";
import { useKisTradeWebSocket } from "@/features/dashboard/hooks/useKisTradeWebSocket"; import { useKisTradeWebSocket } from "@/features/dashboard/hooks/useKisTradeWebSocket";
import { useStockOverview } from "@/features/dashboard/hooks/useStockOverview"; import { useStockOverview } from "@/features/dashboard/hooks/useStockOverview";
import { useCurrentPrice } from "@/features/dashboard/hooks/useCurrentPrice";
import { DashboardLayout } from "@/features/dashboard/components/layout/DashboardLayout"; import { DashboardLayout } from "@/features/dashboard/components/layout/DashboardLayout";
import { StockHeader } from "@/features/dashboard/components/header/StockHeader"; import { StockHeader } from "@/features/dashboard/components/header/StockHeader";
import { OrderBook } from "@/features/dashboard/components/orderbook/OrderBook"; import { OrderBook } from "@/features/dashboard/components/orderbook/OrderBook";
@@ -30,6 +33,11 @@ import type {
*/ */
export function DashboardContainer() { export function DashboardContainer() {
const skipNextAutoSearchRef = useRef(false); const skipNextAutoSearchRef = useRef(false);
const hasInitializedAuthPanelRef = useRef(false);
// 모바일에서는 초기 진입 시 API 키 패널을 접어서 본문(차트/호가)을 먼저 보이게 합니다.
const [isMobileViewport, setIsMobileViewport] = useState(false);
const [isAuthPanelExpanded, setIsAuthPanelExpanded] = useState(true);
const { verifiedCredentials, isKisVerified } = useKisRuntimeStore( const { verifiedCredentials, isKisVerified } = useKisRuntimeStore(
useShallow((state) => ({ useShallow((state) => ({
@@ -88,6 +96,47 @@ export function DashboardContainer() {
}, },
); );
// 3. Price Calculation Logic (Hook)
const {
currentPrice,
change,
changeRate,
prevClose: referencePrice,
} = useCurrentPrice({
stock: selectedStock,
latestTick,
orderBook,
});
useEffect(() => {
const mediaQuery = window.matchMedia("(max-width: 767px)");
const applyViewportMode = (matches: boolean) => {
setIsMobileViewport(matches);
// 최초 1회: 모바일이면 접힘, 데스크탑이면 펼침.
if (!hasInitializedAuthPanelRef.current) {
setIsAuthPanelExpanded(!matches);
hasInitializedAuthPanelRef.current = true;
return;
}
// 데스크탑으로 돌아오면 항상 펼쳐 사용성을 유지합니다.
if (!matches) {
setIsAuthPanelExpanded(true);
}
};
applyViewportMode(mediaQuery.matches);
const onViewportChange = (event: MediaQueryListEvent) => {
applyViewportMode(event.matches);
};
mediaQuery.addEventListener("change", onViewportChange);
return () => mediaQuery.removeEventListener("change", onViewportChange);
}, []);
useEffect(() => { useEffect(() => {
if (skipNextAutoSearchRef.current) { if (skipNextAutoSearchRef.current) {
skipNextAutoSearchRef.current = false; skipNextAutoSearchRef.current = false;
@@ -112,29 +161,6 @@ export function DashboardContainer() {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [keyword, isKisVerified, verifiedCredentials, search, clearSearch]); }, [keyword, isKisVerified, verifiedCredentials, search, clearSearch]);
// Price Calculation Logic
// Prioritize latestTick (Real Exec) > OrderBook Ask1 (Proxy) > REST Data
let currentPrice = selectedStock?.currentPrice;
let change = selectedStock?.change;
let changeRate = selectedStock?.changeRate;
if (latestTick) {
currentPrice = latestTick.price;
change = latestTick.change;
changeRate = latestTick.changeRate;
} else if (orderBook?.levels[0]?.askPrice) {
// Fallback: Use Best Ask Price as proxy for current price
const askPrice = orderBook.levels[0].askPrice;
if (askPrice > 0) {
currentPrice = askPrice;
// Recalculate change/rate based on prevClose
if (selectedStock && selectedStock.prevClose > 0) {
change = currentPrice - selectedStock.prevClose;
changeRate = (change / selectedStock.prevClose) * 100;
}
}
}
function handleSearchSubmit(e: React.FormEvent) { function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!isKisVerified || !verifiedCredentials) { if (!isKisVerified || !verifiedCredentials) {
@@ -167,7 +193,7 @@ export function DashboardContainer() {
<div className="relative h-full flex flex-col"> <div className="relative h-full flex flex-col">
{/* ========== AUTH STATUS ========== */} {/* ========== AUTH STATUS ========== */}
<div className="flex-none border-b bg-muted/40 transition-all duration-300 ease-in-out"> <div className="flex-none border-b bg-muted/40 transition-all duration-300 ease-in-out">
<div className="flex items-center justify-between px-4 py-2 text-xs"> <div className="flex items-center justify-between gap-2 px-3 py-2 text-xs sm:px-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-semibold">KIS API :</span> <span className="font-semibold">KIS API :</span>
{isKisVerified ? ( {isKisVerified ? (
@@ -183,9 +209,40 @@ export function DashboardContainer() {
</span> </span>
)} )}
</div> </div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setIsAuthPanelExpanded((prev) => !prev)}
className={cn(
"h-8 shrink-0 gap-1.5 px-2.5 text-[11px] font-semibold",
"border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100",
!isAuthPanelExpanded && isMobileViewport && "ring-2 ring-brand-200",
)}
>
{isAuthPanelExpanded ? (
<>
<ChevronUp className="h-3.5 w-3.5" />
API
</>
) : (
<>
<ChevronDown className="h-3.5 w-3.5" />
API
</>
)}
</Button>
</div> </div>
<div className="overflow-hidden transition-[max-height,opacity] duration-300 ease-in-out max-h-[500px] opacity-100"> <div
className={cn(
"overflow-hidden transition-[max-height,opacity] duration-300 ease-in-out",
isAuthPanelExpanded
? "max-h-[560px] opacity-100"
: "max-h-0 opacity-0",
)}
>
<div className="p-4 border-t bg-background"> <div className="p-4 border-t bg-background">
<KisAuthForm /> <KisAuthForm />
</div> </div>
@@ -263,7 +320,7 @@ export function DashboardContainer() {
orderBook={ orderBook={
<OrderBook <OrderBook
symbol={selectedStock?.symbol} symbol={selectedStock?.symbol}
referencePrice={selectedStock?.prevClose} referencePrice={referencePrice}
currentPrice={currentPrice} currentPrice={currentPrice}
latestTick={latestTick} latestTick={latestTick}
recentTicks={recentTradeTicks} recentTicks={recentTradeTicks}

View File

@@ -31,28 +31,46 @@ export function StockHeader({
: "text-foreground"; : "text-foreground";
return ( return (
<div className="flex items-center justify-between px-4 py-3"> <div className="px-3 py-2 sm:px-4 sm:py-3">
{/* Left: Stock Info */} {/* ========== STOCK SUMMARY ========== */}
<div className="flex items-center gap-4"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2"> <div className="min-w-0">
<h1 className="text-xl font-bold">{stock.name}</h1> <h1 className="truncate text-lg font-bold leading-tight sm:text-xl">
<span className="text-sm text-muted-foreground"> {stock.name}
</h1>
<span className="mt-0.5 block text-xs text-muted-foreground sm:text-sm">
{stock.symbol}/{stock.market} {stock.symbol}/{stock.market}
</span> </span>
</div> </div>
<Separator orientation="vertical" className="h-6" /> <div className={cn("shrink-0 text-right", colorClass)}>
<span className="block text-2xl font-bold tracking-tight">{price}</span>
<div className={cn("flex items-end gap-2", colorClass)}> <span className="text-xs font-medium sm:text-sm">
<span className="text-2xl font-bold tracking-tight">{price}</span> {changeRate}% <span className="ml-1 text-[11px] sm:text-xs">{change}</span>
<span className="text-sm font-medium mb-1">
{changeRate}% <span className="text-xs ml-1">{change}</span>
</span> </span>
</div> </div>
</div> </div>
{/* Right: 24h Stats */} {/* ========== STATS ========== */}
<div className="hidden md:flex items-center gap-6 text-sm"> <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">
<p className="text-[11px] text-muted-foreground"></p>
<p className="font-medium text-red-500">{high || "--"}</p>
</div>
<div className="rounded-md bg-muted/40 px-2 py-1.5">
<p className="text-[11px] text-muted-foreground"></p>
<p className="font-medium text-blue-500">{low || "--"}</p>
</div>
<div className="rounded-md bg-muted/40 px-2 py-1.5">
<p className="text-[11px] text-muted-foreground">(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"> <div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs"></span> <span className="text-muted-foreground text-xs"></span>
<span className="font-medium text-red-500">{high || "--"}</span> <span className="font-medium text-red-500">{high || "--"}</span>

View File

@@ -19,7 +19,11 @@ export function DashboardLayout({
return ( return (
<div <div
className={cn( className={cn(
"flex h-[calc(100vh-64px)] flex-col overflow-hidden", "flex flex-col bg-background",
// Mobile: Scrollable page height
"min-h-[calc(100vh-64px)]",
// Desktop: Fixed height, no window scroll
"xl:h-[calc(100vh-64px)] xl:overflow-hidden",
className, className,
)} )}
> >
@@ -29,22 +33,37 @@ export function DashboardLayout({
</div> </div>
{/* 2. Main Content Area */} {/* 2. Main Content Area */}
<div className="flex flex-1 flex-col overflow-hidden xl:flex-row"> <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 */} {/* Left Column: Chart & Info */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col border-border xl:border-r"> <div
className={cn(
"flex flex-col border-border",
// 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> <div className="flex-1 min-h-0">{chart}</div>
{/* Future: Transaction History / Market Depth can go here */} {/* Future: Transaction History / Market Depth can go here */}
</div> </div>
{/* Right Column: Order Book & Order Form */} {/* Right Column: Order Book & Order Form */}
<div className="flex min-h-0 w-full flex-none flex-col bg-background xl:w-[460px] 2xl:w-[500px]"> <div className="flex min-h-0 w-full flex-none flex-col bg-background xl:w-[460px] xl:pr-2 2xl:w-[500px]">
{/* Top: Order Book (Hoga) */} {/* Top: Order Book (Hoga) */}
<div className="min-h-[360px] flex-1 overflow-hidden border-t border-border xl:min-h-0 xl:border-t-0 xl:border-b"> <div className="h-[390px] flex-none overflow-hidden border-t border-border sm:h-[430px] xl:min-h-0 xl:flex-1 xl:h-auto xl:border-t-0 xl:border-b">
{orderBook} {orderBook}
</div> </div>
{/* Bottom: Order Form */} {/* Bottom: Order Form */}
<div className="flex-none h-[320px] sm:h-[360px] xl:h-[380px]"> <div className="flex-none h-auto sm:h-auto xl:h-[380px]">
{orderForm} {orderForm}
</div> </div>
</div> </div>

View File

@@ -82,13 +82,13 @@ export function OrderForm({ stock }: OrderFormProps) {
const isMarketDataAvailable = !!stock; const isMarketDataAvailable = !!stock;
return ( return (
<div className="h-full bg-background p-4 border-l border-border"> <div className="h-full border-l border-border bg-background p-3 sm:p-4">
<Tabs <Tabs
value={activeTab} value={activeTab}
onValueChange={setActiveTab} onValueChange={setActiveTab}
className="w-full h-full flex flex-col" className="w-full h-full flex flex-col"
> >
<TabsList className="grid w-full grid-cols-2 mb-4"> <TabsList className="mb-3 grid w-full grid-cols-2 sm:mb-4">
<TabsTrigger <TabsTrigger
value="buy" value="buy"
className="data-[state=active]:bg-red-600 data-[state=active]:text-white transition-colors" className="data-[state=active]:bg-red-600 data-[state=active]:text-white transition-colors"
@@ -105,7 +105,7 @@ export function OrderForm({ stock }: OrderFormProps) {
<TabsContent <TabsContent
value="buy" value="buy"
className="flex-1 flex flex-col space-y-4 data-[state=inactive]:hidden" className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
> >
<OrderInputs <OrderInputs
type="buy" type="buy"
@@ -122,7 +122,7 @@ export function OrderForm({ stock }: OrderFormProps) {
<PercentButtons onSelect={setPercent} /> <PercentButtons onSelect={setPercent} />
<Button <Button
className="w-full bg-red-600 hover:bg-red-700 mt-auto text-lg h-12" className="mt-auto h-11 w-full bg-red-600 text-base hover:bg-red-700 sm:h-12 sm:text-lg"
disabled={isLoading || !isMarketDataAvailable} disabled={isLoading || !isMarketDataAvailable}
onClick={() => handleOrder("buy")} onClick={() => handleOrder("buy")}
> >
@@ -132,7 +132,7 @@ export function OrderForm({ stock }: OrderFormProps) {
<TabsContent <TabsContent
value="sell" value="sell"
className="flex-1 flex flex-col space-y-4 data-[state=inactive]:hidden" className="flex flex-1 flex-col space-y-3 data-[state=inactive]:hidden sm:space-y-4"
> >
<OrderInputs <OrderInputs
type="sell" type="sell"
@@ -149,7 +149,7 @@ export function OrderForm({ stock }: OrderFormProps) {
<PercentButtons onSelect={setPercent} /> <PercentButtons onSelect={setPercent} />
<Button <Button
className="w-full bg-blue-600 hover:bg-blue-700 mt-auto text-lg h-12" className="mt-auto h-11 w-full bg-blue-600 text-base hover:bg-blue-700 sm:h-12 sm:text-lg"
disabled={isLoading || !isMarketDataAvailable} disabled={isLoading || !isMarketDataAvailable}
onClick={() => handleOrder("sell")} onClick={() => handleOrder("sell")}
> >
@@ -183,7 +183,7 @@ function OrderInputs({
errorMessage: string | null; errorMessage: string | null;
}) { }) {
return ( return (
<div className="space-y-4"> <div className="space-y-3 sm:space-y-4">
<div className="flex justify-between text-xs text-muted-foreground"> <div className="flex justify-between text-xs text-muted-foreground">
<span></span> <span></span>
<span>- {type === "buy" ? "KRW" : "주"}</span> <span>- {type === "buy" ? "KRW" : "주"}</span>
@@ -195,8 +195,8 @@ function OrderInputs({
</div> </div>
)} )}
<div className="grid grid-cols-4 gap-2 items-center"> <div className="grid grid-cols-4 items-center gap-2">
<span className="text-sm font-medium"> <span className="text-xs font-medium sm:text-sm">
{type === "buy" ? "매수가격" : "매도가격"} {type === "buy" ? "매수가격" : "매도가격"}
</span> </span>
<Input <Input
@@ -207,8 +207,8 @@ function OrderInputs({
disabled={disabled} disabled={disabled}
/> />
</div> </div>
<div className="grid grid-cols-4 gap-2 items-center"> <div className="grid grid-cols-4 items-center gap-2">
<span className="text-sm font-medium"></span> <span className="text-xs font-medium sm:text-sm"></span>
<Input <Input
className="col-span-3 text-right font-mono" className="col-span-3 text-right font-mono"
placeholder="0" placeholder="0"
@@ -217,8 +217,8 @@ function OrderInputs({
disabled={disabled} disabled={disabled}
/> />
</div> </div>
<div className="grid grid-cols-4 gap-2 items-center"> <div className="grid grid-cols-4 items-center gap-2">
<span className="text-sm font-medium"></span> <span className="text-xs font-medium sm:text-sm"></span>
<Input <Input
className="col-span-3 text-right font-mono bg-muted/50" className="col-span-3 text-right font-mono bg-muted/50"
value={totalPrice.toLocaleString()} value={totalPrice.toLocaleString()}

View File

@@ -164,17 +164,17 @@ export function OrderBook({
{/* ── 일반호가 탭 ── */} {/* ── 일반호가 탭 ── */}
<TabsContent value="normal" className="min-h-0 flex-1"> <TabsContent value="normal" className="min-h-0 flex-1">
<div className="grid h-full min-h-0 grid-rows-[1fr_190px] overflow-hidden border-t"> <div className="block h-full min-h-0 border-t xl:grid xl:grid-rows-[1fr_190px] xl:overflow-hidden">
<div className="grid min-h-0 grid-cols-[minmax(0,1fr)_150px] 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 border-r"> <div className="min-h-0 xl:border-r">
<BookHeader /> <BookHeader />
<ScrollArea className="h-[calc(100%-32px)]"> <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} /> <BookSideRows rows={askRows} side="ask" maxSize={askMax} />
{/* 중앙 바: 현재 체결가 */} {/* 중앙 바: 현재 체결가 */}
<div className="grid h-9 grid-cols-3 items-center border-y-2 border-amber-400 bg-amber-50/80 dark:bg-amber-900/30"> <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"> <div className="px-2 text-right text-[10px] font-medium text-muted-foreground">
{totalAsk > 0 ? fmt(totalAsk) : ""} {totalAsk > 0 ? fmt(totalAsk) : ""}
</div> </div>
@@ -210,18 +210,22 @@ export function OrderBook({
</div> </div>
{/* 우측 요약 패널 */} {/* 우측 요약 패널 */}
<SummaryPanel <div className="hidden xl:block">
orderBook={orderBook} <SummaryPanel
latestTick={latestTick} orderBook={orderBook}
spread={spread} latestTick={latestTick}
imbalance={imbalance} spread={spread}
totalAsk={totalAsk} imbalance={imbalance}
totalBid={totalBid} totalAsk={totalAsk}
/> totalBid={totalBid}
/>
</div>
</div> </div>
{/* 체결 목록 */} {/* 체결 목록 */}
<TradeTape ticks={recentTicks} /> <div className="hidden xl:block">
<TradeTape ticks={recentTicks} />
</div>
</div> </div>
</TabsContent> </TabsContent>
@@ -285,7 +289,7 @@ function BookSideRows({
<div <div
key={`${side}-${row.price}-${i}`} key={`${side}-${row.price}-${i}`}
className={cn( className={cn(
"grid h-8 grid-cols-3 border-b border-border/40 text-xs", "grid h-7 grid-cols-3 border-b border-border/40 text-[11px] xl:h-8 xl:text-xs",
row.isHighlighted && row.isHighlighted &&
"ring-1 ring-inset ring-amber-400 bg-amber-100/50 dark:bg-amber-900/30", "ring-1 ring-inset ring-amber-400 bg-amber-100/50 dark:bg-amber-900/30",
)} )}
@@ -368,7 +372,7 @@ function SummaryPanel({
totalBid: number; totalBid: number;
}) { }) {
return ( return (
<div className="border-l bg-muted/15 p-2 text-[11px]"> <div className="min-w-0 border-l bg-muted/15 p-2 text-[11px]">
<Row <Row
label="실시간" label="실시간"
value={orderBook ? "연결됨" : "끊김"} value={orderBook ? "연결됨" : "끊김"}
@@ -440,11 +444,11 @@ function Row({
tone?: "ask" | "bid"; tone?: "ask" | "bid";
}) { }) {
return ( return (
<div className="mb-1.5 flex items-center justify-between rounded border bg-background px-2 py-1"> <div className="mb-1.5 flex items-center justify-between gap-2 rounded border bg-background px-2 py-1">
<span className="text-muted-foreground">{label}</span> <span className="min-w-0 truncate text-muted-foreground">{label}</span>
<span <span
className={cn( className={cn(
"font-medium tabular-nums", "shrink-0 font-medium tabular-nums",
tone === "ask" && "text-red-600", tone === "ask" && "text-red-600",
tone === "bid" && "text-blue-600", tone === "bid" && "text-blue-600",
)} )}

View File

@@ -0,0 +1,53 @@
import { useMemo } from "react";
import type {
DashboardRealtimeTradeTick,
DashboardStockItem,
DashboardStockOrderBookResponse,
} from "@/features/dashboard/types/dashboard.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]);
}

View File

@@ -9,43 +9,54 @@ import { MenuItem } from "../types";
const MENU_ITEMS: MenuItem[] = [ const MENU_ITEMS: MenuItem[] = [
{ {
title: "대시보드", title: "대시보드",
href: "/", href: "/dashboard",
icon: Home, icon: Home,
variant: "default", variant: "default",
matchExact: true, matchExact: true,
showInBottomNav: true,
}, },
{ {
title: "자동매매", title: "자동매매",
href: "/trade", href: "/trade",
icon: BarChart2, icon: BarChart2,
variant: "ghost", variant: "ghost",
badge: "LIVE",
showInBottomNav: true,
}, },
{ {
title: "자산현황", title: "자산현황",
href: "/assets", href: "/assets",
icon: Wallet, icon: Wallet,
variant: "ghost", variant: "ghost",
showInBottomNav: true,
}, },
{ {
title: "프로필", title: "프로필",
href: "/profile", href: "/profile",
icon: User, icon: User,
variant: "ghost", variant: "ghost",
showInBottomNav: false,
}, },
{ {
title: "설정", title: "설정",
href: "/settings", href: "/settings",
icon: Settings, icon: Settings,
variant: "ghost", variant: "ghost",
showInBottomNav: true,
}, },
]; ];
/**
* @description 메인 좌측 사이드바(데스크탑): 기본 축소 상태에서 hover/focus 시 확장됩니다.
* @see features/layout/components/sidebar.tsx MENU_ITEMS 한 곳에서 메뉴/배지/모바일 탭 구성을 함께 관리합니다.
*/
export function Sidebar() { export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
<aside className="hidden h-[calc(100vh-4rem)] 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:top-16 md:block md:w-64 lg:w-72"> <aside className="group/sidebar hidden h-[calc(100vh-4rem)] shrink-0 overflow-y-auto border-r border-zinc-200 bg-white px-2 py-5 transition-[width] duration-200 dark:border-zinc-800 dark:bg-black md:sticky md:top-16 md:block md:w-[74px] md:hover:w-64 md:focus-within:w-64">
<div className="flex flex-col space-y-1"> {/* ========== SIDEBAR ITEMS ========== */}
<div className="flex flex-col space-y-1.5">
{MENU_ITEMS.map((item) => { {MENU_ITEMS.map((item) => {
const isActive = item.matchExact const isActive = item.matchExact
? pathname === item.href ? pathname === item.href
@@ -55,22 +66,46 @@ export function Sidebar() {
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
title={item.title}
className={cn( 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-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
isActive isActive
? "bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50" ? "bg-zinc-100 text-zinc-900 shadow-sm dark:bg-zinc-800 dark:text-zinc-50"
: "text-zinc-500 dark:text-zinc-400", : "text-zinc-500 dark:text-zinc-400",
)} )}
> >
<item.icon {/* ========== ACTIVE BAR ========== */}
<span
className={cn( className={cn(
"mr-3 h-5 w-5 shrink-0 transition-colors", "absolute left-0 top-1/2 h-5 -translate-y-1/2 rounded-r-full transition-all",
isActive isActive ? "w-1.5 bg-brand-500" : "w-0",
? "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",
)} )}
/> />
{item.title}
{/* ========== ICON + DOT BADGE ========== */}
<item.icon
className={cn(
"h-5 w-5 shrink-0 transition-colors",
isActive
? "text-zinc-900 dark:text-zinc-50"
: "text-zinc-400 group-hover/item:text-zinc-900 dark:text-zinc-500 dark:group-hover/item:text-zinc-50",
)}
/>
{item.badge && (
<span className="absolute left-7 top-2 h-2 w-2 rounded-full bg-brand-500 md:group-hover/sidebar:hidden md:group-focus-within/sidebar:hidden" />
)}
{/* ========== LABEL (EXPAND ON HOVER/FOCUS) ========== */}
<span className="ml-3 flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap transition-all duration-200 md:max-w-0 md:opacity-0 md:group-hover/sidebar:max-w-[180px] md:group-hover/sidebar:opacity-100 md:group-focus-within/sidebar:max-w-[180px] md:group-focus-within/sidebar:opacity-100">
<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> </Link>
); );
})} })}
@@ -78,3 +113,49 @@ export function Sidebar() {
</aside> </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-zinc-200 bg-white/95 backdrop-blur-sm supports-backdrop-filter:bg-white/80 dark:border-zinc-800 dark:bg-black/95 dark:supports-backdrop-filter:bg-black/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-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50",
)}
>
<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

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

View File

@@ -0,0 +1,61 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- generic [ref=e2]:
- banner [ref=e3]:
- generic [ref=e4]:
- link "AutoTrade" [ref=e5] [cursor=pointer]:
- /url: /
- generic [ref=e8]: AutoTrade
- generic [ref=e9]:
- button "Toggle theme" [ref=e10]:
- img
- generic [ref=e11]: Toggle theme
- link "시작하기" [ref=e13] [cursor=pointer]:
- /url: /signup
- main [ref=e18]:
- generic [ref=e20]:
- generic [ref=e21]:
- generic [ref=e23]: 👋
- generic [ref=e24]: 환영합니다!
- generic [ref=e25]: 서비스 이용을 위해 로그인해 주세요.
- generic [ref=e27]:
- generic [ref=e28]:
- generic [ref=e29]:
- generic [ref=e30]: 이메일
- textbox "이메일" [ref=e31]:
- /placeholder: your@email.com
- generic [ref=e32]:
- generic [ref=e33]: 비밀번호
- textbox "비밀번호" [ref=e34]:
- /placeholder: ••••••••
- generic [ref=e35]:
- generic [ref=e36]:
- checkbox "이메일 기억하기" [ref=e37]
- checkbox
- generic [ref=e38] [cursor=pointer]: 이메일 기억하기
- link "비밀번호 찾기" [ref=e39] [cursor=pointer]:
- /url: /forgot-password
- button "로그인" [ref=e40]
- paragraph [ref=e41]:
- text: 계정이 없으신가요?
- link "회원가입 하기" [ref=e42] [cursor=pointer]:
- /url: /signup
- generic [ref=e44]: 또는 소셜 로그인
- generic [ref=e45]:
- button "Google" [ref=e47]:
- img
- text: Google
- button "Kakao" [ref=e49]:
- img
- text: Kakao
- generic [ref=e50]:
- img [ref=e52]
- button "Open Tanstack query devtools" [ref=e100] [cursor=pointer]:
- img [ref=e101]
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e154] [cursor=pointer]:
- img [ref=e155]
- alert [ref=e158]
```

View File

@@ -82,4 +82,4 @@ Error generating stack: `+a.message+`
<div id='root'></div> <div id='root'></div>
</body> </body>
</html> </html>
<script id="playwrightReportBase64" type="application/zip">data:application/zip;base64,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</script> <script id="playwrightReportBase64" type="application/zip">data:application/zip;base64,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</script>

View File

@@ -1,4 +1,6 @@
{ {
"status": "passed", "status": "failed",
"failedTests": [] "failedTests": [
"2746d92cb07c1216e72c-59289721e6ad6cc3d2d4"
]
} }

View File

@@ -0,0 +1,61 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- generic [ref=e2]:
- banner [ref=e3]:
- generic [ref=e4]:
- link "AutoTrade" [ref=e5] [cursor=pointer]:
- /url: /
- generic [ref=e8]: AutoTrade
- generic [ref=e9]:
- button "Toggle theme" [ref=e10]:
- img
- generic [ref=e11]: Toggle theme
- link "시작하기" [ref=e13] [cursor=pointer]:
- /url: /signup
- main [ref=e18]:
- generic [ref=e20]:
- generic [ref=e21]:
- generic [ref=e23]: 👋
- generic [ref=e24]: 환영합니다!
- generic [ref=e25]: 서비스 이용을 위해 로그인해 주세요.
- generic [ref=e27]:
- generic [ref=e28]:
- generic [ref=e29]:
- generic [ref=e30]: 이메일
- textbox "이메일" [ref=e31]:
- /placeholder: your@email.com
- generic [ref=e32]:
- generic [ref=e33]: 비밀번호
- textbox "비밀번호" [ref=e34]:
- /placeholder: ••••••••
- generic [ref=e35]:
- generic [ref=e36]:
- checkbox "이메일 기억하기" [ref=e37]
- checkbox
- generic [ref=e38] [cursor=pointer]: 이메일 기억하기
- link "비밀번호 찾기" [ref=e39] [cursor=pointer]:
- /url: /forgot-password
- button "로그인" [ref=e40]
- paragraph [ref=e41]:
- text: 계정이 없으신가요?
- link "회원가입 하기" [ref=e42] [cursor=pointer]:
- /url: /signup
- generic [ref=e44]: 또는 소셜 로그인
- generic [ref=e45]:
- button "Google" [ref=e47]:
- img
- text: Google
- button "Kakao" [ref=e49]:
- img
- text: Kakao
- generic [ref=e50]:
- img [ref=e52]
- button "Open Tanstack query devtools" [ref=e100] [cursor=pointer]:
- img [ref=e101]
- region "Notifications alt+T"
- button "Open Next.js Dev Tools" [ref=e154] [cursor=pointer]:
- img [ref=e155]
- alert [ref=e158]
```

View File

@@ -0,0 +1,67 @@
import { test, expect } from "@playwright/test";
test.describe("Mobile Dashboard Layout", () => {
test.use({
viewport: { width: 390, height: 844 }, // iPhone 12 Pro size
userAgent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Mobile/15E148 Safari/604.1",
});
test("should verify mobile layout elements are visible and not overlapping", async ({
page,
}) => {
// 1. 대시보드 페이지 이동
await page.goto("/dashboard");
// 페이지 로딩 대기 (네트워크 유휴 상태 혹은 특정 요소)
await page.waitForLoadState("networkidle");
// 2. 주요 섹션 존재 여부 확인
const chartSection = page.locator("text=차트 영역, O, H, L, C").first(); // 차트 관련 텍스트나 영역
// StockLineChart가 로드되면 차트 캔버스가 있을 것임
const chartCanvas = page.locator("canvas").first();
// 호가창 탭
const orderBookTabs = page.getByRole("tablist");
// 주문 폼 탭
const orderFormTabs = page.getByRole("tab", { name: "매수" });
// 3. 스크린샷 캡처 (디버깅용)
await page.screenshot({
path: "test-results/mobile-dashboard-before-fix.png",
fullPage: true,
});
// 4. 요소 가시성 체크 (현재 고정 높이 버그로 인해 겹치거나 잘릴 수 있음)
// 수정 전에는 이것들이 겹쳐있거나 뷰포트 밖으로 밀려나도 스크롤이 안될 수 있음.
// 체크: body에 스크롤이 생겼는지 확인 (수정 후에는 생겨야 함)
// 현재(버그 상태)는 overflow-hidden이라 스크롤이 없을 것임.
// 5. 레이아웃 검증 로직
// 차트 높이가 충분한지
const chartBox = await page
.locator(".flex-1.min-h-0")
.first()
.boundingBox();
console.log("Chart Box:", chartBox);
// 호가창이 보이는지
const orderBookBox = await page
.locator("text=일반호가")
.first()
.boundingBox();
console.log("OrderBook Box:", orderBookBox);
// 주문폼이 보이는지
const orderFormBox = await page
.locator("text=매수하기")
.first()
.boundingBox();
console.log("OrderForm Box:", orderFormBox);
// 단순 존재 여부만 통과시키고, 실제 시각적 확인은 스크린샷으로 대체
expect(page.url()).toContain("/dashboard");
});
});

View File

@@ -0,0 +1,47 @@
import { test, expect } from "@playwright/test";
test.describe("Mobile Dashboard Scroll", () => {
test.use({
viewport: { width: 390, height: 844 }, // iPhone 12 Pro size
userAgent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Mobile/15E148 Safari/604.1",
});
test("should allow scrolling to access order form at the bottom", async ({
page,
}) => {
// 1. Navigate to dashboard
await page.goto("http://localhost:3001/dashboard");
await page.waitForLoadState("domcontentloaded");
// 2. Check Top Element (Chart)
const chart = page.locator("canvas").first();
await expect(chart).toBeVisible();
// 3. Scroll to Bottom
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(500); // Wait for scroll
// 4. Check Bottom Element (Order Form)
// "매수하기" button is a good indicator of the order form
const buyButton = page.getByRole("button", { name: "매수하기" });
await expect(buyButton).toBeVisible();
// 5. Verify Scroll Height is greater than Viewport Height
const scrollHeight = await page.evaluate(
() => document.documentElement.scrollHeight,
);
const viewportHeight = 844;
expect(scrollHeight).toBeGreaterThan(viewportHeight);
console.log(
`Scroll Height: ${scrollHeight}, Viewport Height: ${viewportHeight}`,
);
// Capture screenshot at bottom
await page.screenshot({
path: "test-results/mobile-scroll-bottom.png",
fullPage: false,
});
});
});