Преглед на файлове

feat(ui): D6 디자인 시스템 — DataTable·Ctrl+K 검색·티커 컴포넌트

- components/table (DataTable/StockRow/PostRow/PriceChange) + data-table.scss
- cmdk 기반 SearchCommand + Header/HeaderSearch 통합
- package: cmdk 의존 추가

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 преди 2 седмици
родител
ревизия
6e0479ec5a

+ 7 - 0
app/component/Header.tsx

@@ -10,6 +10,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { faBars, faXmark, faCartShopping, faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
 import { faBars, faXmark, faCartShopping, faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
 import NotificationBell from '@/app/component/NotificationBell';
 import NotificationBell from '@/app/component/NotificationBell';
 import HeaderSearch from '@/app/component/HeaderSearch';
 import HeaderSearch from '@/app/component/HeaderSearch';
+import SearchCommand from '@/app/component/SearchCommand';
 import Profile from '@/app/component/Profile';
 import Profile from '@/app/component/Profile';
 import Logo from '@/app/component/Logo';
 import Logo from '@/app/component/Logo';
 import PointChargeIcon from '@/public/icons/layout/point.svg';
 import PointChargeIcon from '@/public/icons/layout/point.svg';
@@ -23,9 +24,12 @@ const channelNavItems = [
 ];
 ];
 
 
 const baseNavItems = [
 const baseNavItems = [
+	{ href: '/market', label: '시장' },
 	{ href: '/stock', label: '종목' },
 	{ href: '/stock', label: '종목' },
 	{ href: '/paper', label: '모의투자' },
 	{ href: '/paper', label: '모의투자' },
 	{ href: '/games', label: '게임' },
 	{ href: '/games', label: '게임' },
+	{ href: '/board/briefing', label: '장전브리핑' },
+	{ href: '/board/proof', label: '수익인증' },
 	{ href: '/feed/all', label: '피드' },
 	{ href: '/feed/all', label: '피드' },
 	{ href: '/chat', label: '채팅' },
 	{ href: '/chat', label: '채팅' },
 	{ href: '/store', label: '상점' },
 	{ href: '/store', label: '상점' },
@@ -143,6 +147,9 @@ export default function Header({ sidebarOpen, onToggle }: Props)
 
 
 			{/* 지수 티커 스트립 — TradingView 임베드 컨테이너 (규격만, 내용 주입 전까지 :empty 로 미표시) */}
 			{/* 지수 티커 스트립 — TradingView 임베드 컨테이너 (규격만, 내용 주입 전까지 :empty 로 미표시) */}
 			<div id='header-ticker' className={Styles.tickerStrip} aria-hidden='true' />
 			<div id='header-ticker' className={Styles.tickerStrip} aria-hidden='true' />
+
+			{/* Ctrl+K / ⌘K 커맨드 팔레트 — 전역 (렌더 없음, 열릴 때만 표시) */}
+			<SearchCommand />
 		</header>
 		</header>
 	);
 	);
 }
 }

+ 1 - 7
app/component/HeaderSearch.tsx

@@ -25,15 +25,9 @@ export default function HeaderSearch()
 	const [open, setOpen] = useState(false);
 	const [open, setOpen] = useState(false);
 	const [activeIndex, setActiveIndex] = useState(-1);
 	const [activeIndex, setActiveIndex] = useState(-1);
 
 
-	// 통합 검색 포커스 단축키: Ctrl+K(⌘+K) / '/' (finviz 관례)
+	// 인라인 검색 포커스 단축키: '/' (finviz 관례). Ctrl+K(⌘K)는 커맨드 팔레트(SearchCommand)가 소유.
 	useEffect(() => {
 	useEffect(() => {
 		const handler = (e: KeyboardEvent) => {
 		const handler = (e: KeyboardEvent) => {
-			if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
-				e.preventDefault();
-				inputRef.current?.focus();
-				return;
-			}
-
 			if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey) {
 			if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey) {
 				const target = e.target as HTMLElement|null;
 				const target = e.target as HTMLElement|null;
 				const tag = target?.tagName;
 				const tag = target?.tagName;

+ 161 - 0
app/component/SearchCommand.tsx

@@ -0,0 +1,161 @@
+'use client';
+
+// D6 §5.2 — Ctrl+K 커맨드 팔레트 (shadcn Command / cmdk)
+// 종목 자동완성(/api/stocks/suggest) + 글 검색(Forum) 합성 (d0 §3-D 확정).
+// 종목 선택 → /stock/{code} 즉시 이동, 글 검색 → /latest?search=subject&keyword=... 이동.
+// 헤더 인라인 combobox(HeaderSearch)와 별개로, 전역 Ctrl+K/⌘K 로 열리는 팔레트.
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { fetchApi } from '@/lib/utils/client';
+import { StockSuggestItem, StockSuggestResponse } from '@/types/stock';
+import {
+	CommandDialog,
+	CommandInput,
+	CommandList,
+	CommandEmpty,
+	CommandGroup,
+	CommandItem,
+	CommandShortcut,
+	CommandSeparator,
+} from '@/components/ui/command';
+
+const DEBOUNCE_MS = 250;
+
+export default function SearchCommand()
+{
+	const router = useRouter();
+	const [open, setOpen] = useState(false);
+	const [keyword, setKeyword] = useState('');
+	const [items, setItems] = useState<StockSuggestItem[]>([]);
+	const debounceRef = useRef<ReturnType<typeof setTimeout>|null>(null);
+	const requestSeq = useRef(0);
+
+	// 전역 단축키: Ctrl+K / ⌘K → 팔레트 토글
+	useEffect(() => {
+		const handler = (e: KeyboardEvent) => {
+			if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
+				e.preventDefault();
+				setOpen(prev => !prev);
+			}
+		};
+
+		window.addEventListener('keydown', handler);
+		return () => {
+			window.removeEventListener('keydown', handler);
+		};
+	}, []);
+
+	// 닫힐 때 입력/결과 초기화
+	useEffect(() => {
+		if (!open) {
+			setKeyword('');
+			setItems([]);
+			requestSeq.current++;
+		}
+	}, [open]);
+
+	useEffect(() => {
+		return () => {
+			if (debounceRef.current) {
+				clearTimeout(debounceRef.current);
+			}
+		};
+	}, []);
+
+	const requestSuggest = useCallback(async (q: string) => {
+		const seq = ++requestSeq.current;
+		const res = await fetchApi<StockSuggestResponse>(`/api/stocks/suggest?q=${encodeURIComponent(q)}`, { silent: true });
+
+		// out-of-order 응답 무시
+		if (seq !== requestSeq.current) {
+			return;
+		}
+
+		setItems(res.success ? (res.data?.list ?? []) : []);
+	}, []);
+
+	const handleValueChange = useCallback((value: string) => {
+		setKeyword(value);
+
+		if (debounceRef.current) {
+			clearTimeout(debounceRef.current);
+		}
+
+		const trimmed = value.trim();
+		if (!trimmed) {
+			requestSeq.current++;
+			setItems([]);
+			return;
+		}
+
+		debounceRef.current = setTimeout(() => {
+			requestSuggest(trimmed);
+		}, DEBOUNCE_MS);
+	}, [requestSuggest]);
+
+	const goStock = useCallback((code: string) => {
+		setOpen(false);
+		router.push(`/stock/${code}`);
+	}, [router]);
+
+	const goStockSearch = useCallback((q: string) => {
+		setOpen(false);
+		router.push(`/stock?q=${encodeURIComponent(q)}`);
+	}, [router]);
+
+	const goPostSearch = useCallback((q: string) => {
+		setOpen(false);
+		router.push(`/latest?search=subject&keyword=${encodeURIComponent(q)}`);
+	}, [router]);
+
+	const trimmed = keyword.trim();
+
+	return (
+		<CommandDialog open={open} onOpenChange={setOpen} label='통합 검색' shouldFilter={false}>
+			<CommandInput
+				placeholder='종목·글 검색 (예: 삼성전자, 005930)'
+				value={keyword}
+				onValueChange={handleValueChange}
+			/>
+			<CommandList>
+				{!trimmed && (
+					<CommandEmpty>검색어를 입력하세요.</CommandEmpty>
+				)}
+
+				{trimmed && items.length === 0 && (
+					<CommandEmpty>일치하는 종목이 없습니다.</CommandEmpty>
+				)}
+
+				{items.length > 0 && (
+					<CommandGroup heading='종목'>
+						{items.map(item => (
+							<CommandItem
+								key={item.code}
+								value={`stock-${item.code}`}
+								onSelect={() => goStock(item.code)}
+							>
+								<span className='flex-1 truncate'>{item.name}</span>
+								<span className='tabular-nums text-muted-foreground'>{item.code}</span>
+								<CommandShortcut>{item.market}</CommandShortcut>
+							</CommandItem>
+						))}
+					</CommandGroup>
+				)}
+
+				{trimmed && (
+					<>
+						<CommandSeparator />
+						<CommandGroup heading='검색'>
+							<CommandItem value='search-stock' onSelect={() => goStockSearch(trimmed)}>
+								종목 목록에서 &lsquo;{trimmed}&rsquo; 검색
+							</CommandItem>
+							<CommandItem value='search-post' onSelect={() => goPostSearch(trimmed)}>
+								게시글에서 &lsquo;{trimmed}&rsquo; 검색
+							</CommandItem>
+						</CommandGroup>
+					</>
+				)}
+			</CommandList>
+		</CommandDialog>
+	);
+}

+ 200 - 0
app/styles/data-table.scss

@@ -0,0 +1,200 @@
+// ─────────────────────────────────────────────────────────────
+// D6 §5.1 — 고밀도 DataTable 스타일 (components/table/DataTable.tsx)
+// 원칙: 행 구분 하단 1px 선만(세로선·zebra 없음, nof1/monotaro), 숫자열 우측 정렬 + tabular-nums,
+//       sticky thead, 밀도 3단(html[data-density] 변수: --row-h/--cell-fs/--cell-px),
+//       우선순위 낮은 열 mobile drop(data-priority='low'), 포인트 컬러는 등락에 미사용.
+// ─────────────────────────────────────────────────────────────
+
+.data-table {
+	width: 100%;
+	border-collapse: collapse;
+	font-variant-numeric: tabular-nums;
+
+	// caption — 기본 sr-only(접근성), captionVisible 시 표시
+	.data-table__caption {
+		padding: var(--sp-3) 0;
+		font-size: var(--fs-sm);
+		color: var(--text-muted);
+		text-align: left;
+		caption-side: top;
+	}
+
+	.data-table__caption--sr {
+		position: absolute;
+		width: 1px;
+		height: 1px;
+		padding: 0;
+		overflow: hidden;
+		clip: rect(0 0 0 0);
+		white-space: nowrap;
+		border: 0;
+	}
+
+	thead th {
+		position: sticky;
+		top: 0;
+		z-index: 1;
+		height: var(--row-h);
+		padding: 0 var(--cell-px);
+		font-size: var(--cell-fs);
+		font-weight: 600;
+		color: var(--text-muted);
+		background: var(--bg-elevated);
+		border-bottom: 1px solid var(--border-strong);
+		white-space: nowrap;
+	}
+
+	tbody td {
+		height: var(--row-h);
+		padding: 0 var(--cell-px);
+		font-size: var(--cell-fs);
+		color: var(--text-primary);
+		border-bottom: 1px solid var(--border-light);
+		white-space: nowrap;
+	}
+
+	// 정렬 (data-align) — 헤더/바디 공통
+	[data-align='left'] {
+		text-align: left;
+	}
+
+	[data-align='right'] {
+		text-align: right;
+	}
+
+	[data-align='center'] {
+		text-align: center;
+	}
+
+	tbody tr:hover {
+		background: var(--bg-subtle-hover);
+	}
+
+	.data-table__row--clickable {
+		cursor: pointer;
+	}
+
+	// 정렬 가능 헤더 버튼
+	.data-table__sort {
+		display: inline-flex;
+		align-items: center;
+		gap: var(--sp-2);
+		padding: 0;
+		font: inherit;
+		color: inherit;
+		background: none;
+		border: none;
+		cursor: pointer;
+
+		&:hover {
+			color: var(--text-secondary);
+		}
+	}
+
+	.data-table__sort-icon {
+		font-size: var(--fs-2xs);
+		color: var(--text-muted);
+	}
+
+	// 이름/제목 셀 — 말줄임 (가변 폭)
+	.data-table__cell--name {
+		max-width: 0;
+		width: 40%;
+	}
+
+	// 빈 상태
+	.data-table__empty {
+		padding: var(--sp-7) var(--sp-5);
+		font-size: var(--fs-base);
+		color: var(--text-muted);
+		text-align: center;
+	}
+
+	// 우선순위 낮은 열 mobile drop (<768px)
+	@media (max-width: 767.98px) {
+		[data-priority='low'] {
+			display: none;
+		}
+	}
+}
+
+// ── 종목 셀 (StockRow) ──
+.stock-cell {
+	display: flex;
+	align-items: center;
+	gap: var(--sp-3);
+	min-height: var(--row-h);
+	color: var(--text-primary);
+
+	&:hover .stock-cell__name {
+		text-decoration: underline;
+	}
+
+	&__name {
+		overflow: hidden;
+		white-space: nowrap;
+		text-overflow: ellipsis;
+	}
+
+	&__code {
+		flex: 0 0 auto;
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+		font-variant-numeric: tabular-nums;
+	}
+}
+
+// ── 게시글 셀 (PostRow) ──
+.post-cell {
+	display: flex;
+	align-items: center;
+	gap: var(--sp-2);
+	min-height: var(--row-h);
+	color: var(--text-primary);
+
+	&:hover .post-cell__subject {
+		text-decoration: underline;
+	}
+
+	&__prefix {
+		flex: 0 0 auto;
+		color: var(--text-muted);
+	}
+
+	&__subject {
+		overflow: hidden;
+		white-space: nowrap;
+		text-overflow: ellipsis;
+	}
+
+	&__comments {
+		flex: 0 0 auto;
+		font-size: var(--fs-xs);
+		color: var(--az-accent);
+		font-variant-numeric: tabular-nums;
+	}
+}
+
+// ── 등락 표시 (PriceChange) — 색 + 부호 병행 (색맹 대응), 포인트 컬러 미사용 ──
+.price-change {
+	display: inline-flex;
+	align-items: baseline;
+	gap: 2px;
+	font-variant-numeric: tabular-nums;
+
+	&__symbol {
+		font-size: var(--fs-2xs);
+	}
+
+	&--up {
+		color: var(--price-up);
+	}
+
+	&--down {
+		color: var(--price-down);
+	}
+
+	&--flat {
+		color: var(--price-flat);
+	}
+}

+ 140 - 0
components/table/DataTable.tsx

@@ -0,0 +1,140 @@
+'use client';
+
+// D6 §5.1 — 고밀도 "선/테이블 클래식" DataTable
+// 시맨틱 <table> 필수(div 그리드 금지). sticky <thead>, 행 구분 하단 1px 선만(세로선·zebra 없음),
+// 숫자열 우측 정렬 + tabular-nums, 밀도 3단(html[data-density] 변수 소비 — data-table.scss),
+// 우선순위 낮은 열은 mobile drop(data-priority), 정렬 열 aria-sort, <caption> 필수(sr-only 허용).
+import '@/app/styles/data-table.scss';
+import { cn } from '@/lib/utils/client';
+
+export type ColumnAlign = 'left'|'right'|'center';
+export type ColumnPriority = 'high'|'medium'|'low';
+export type SortDirection = 'asc'|'desc';
+
+export interface DataTableColumn<T> {
+	// 열 식별자
+	key: string;
+	// 헤더 라벨
+	header: React.ReactNode;
+	// 셀 렌더러
+	cell: (_row: T, _index: number) => React.ReactNode;
+	// 정렬 (기본 left, 숫자열은 right)
+	align?: ColumnAlign;
+	// 우선순위 — low 는 mobile(<768px)에서 숨김 (data-priority)
+	priority?: ColumnPriority;
+	// 정렬 가능 열이면 방향(없으면 미정렬), 클릭 핸들러는 onSort
+	sort?: SortDirection|null;
+	// 헤더 셀 추가 className
+	headerClassName?: string;
+	// 바디 셀 추가 className
+	cellClassName?: string;
+}
+
+interface DataTableProps<T> {
+	// sr-only caption (접근성 — 표 요약). 화면 표시하려면 captionVisible
+	caption: string;
+	captionVisible?: boolean;
+	columns: DataTableColumn<T>[];
+	rows: T[];
+	rowKey: (_row: T, _index: number) => string|number;
+	// 행 클릭/링크 — 반환값(href)이 있으면 첫 셀에 링크 래핑은 컬럼 cell 이 담당. 여기선 tr onClick 만.
+	onRowClick?: (_row: T, _index: number) => void;
+	// 정렬 헤더 클릭
+	onSort?: (_key: string) => void;
+	// 빈 상태 메시지
+	emptyMessage?: string;
+	className?: string;
+}
+
+function ariaSort(sort: SortDirection|null|undefined): 'ascending'|'descending'|'none'|undefined {
+	if (sort === 'asc') {
+		return 'ascending';
+	}
+
+	if (sort === 'desc') {
+		return 'descending';
+	}
+
+	// 정렬 가능 열이지만 현재 미적용(null)은 'none', 정렬 불가 열(undefined)은 aria-sort 미부여
+	return sort === null ? 'none' : undefined;
+}
+
+export default function DataTable<T>({
+	caption,
+	captionVisible = false,
+	columns,
+	rows,
+	rowKey,
+	onRowClick,
+	onSort,
+	emptyMessage = '표시할 항목이 없습니다.',
+	className,
+}: DataTableProps<T>)
+{
+	return (
+		<table className={cn('data-table', className)}>
+			<caption className={captionVisible ? 'data-table__caption' : 'data-table__caption data-table__caption--sr'}>
+				{caption}
+			</caption>
+			<thead>
+				<tr>
+					{columns.map(col => {
+						const sortable = col.sort !== undefined;
+
+						return (
+							<th
+								key={col.key}
+								scope='col'
+								data-align={col.align ?? 'left'}
+								data-priority={col.priority ?? 'high'}
+								aria-sort={ariaSort(col.sort)}
+								className={col.headerClassName}
+							>
+								{sortable && onSort ? (
+									<button
+										type='button'
+										className='data-table__sort'
+										onClick={() => onSort(col.key)}
+									>
+										{col.header}
+										<span className='data-table__sort-icon' aria-hidden='true'>
+											{col.sort === 'asc' ? '▲' : col.sort === 'desc' ? '▼' : '⇅'}
+										</span>
+									</button>
+								) : (
+									col.header
+								)}
+							</th>
+						);
+					})}
+				</tr>
+			</thead>
+			<tbody>
+				{rows.length === 0 ? (
+					<tr className='data-table__empty-row'>
+						<td colSpan={columns.length} className='data-table__empty'>{emptyMessage}</td>
+					</tr>
+				) : (
+					rows.map((row, index) => (
+						<tr
+							key={rowKey(row, index)}
+							onClick={onRowClick ? () => onRowClick(row, index) : undefined}
+							className={onRowClick ? 'data-table__row--clickable' : undefined}
+						>
+							{columns.map(col => (
+								<td
+									key={col.key}
+									data-align={col.align ?? 'left'}
+									data-priority={col.priority ?? 'high'}
+									className={col.cellClassName}
+								>
+									{col.cell(row, index)}
+								</td>
+							))}
+						</tr>
+					))
+				)}
+			</tbody>
+		</table>
+	);
+}

+ 81 - 0
components/table/PostRow.tsx

@@ -0,0 +1,81 @@
+'use client';
+
+// D6 §5.1 — 게시글행(PostRow) 열 구성 빌더 + PostDataTable 편의 래퍼.
+// 제목(말줄임) + 댓글수 | 작성자 | 작성일 | 조회 | 추천 (도미노 패턴, 우측 정렬 카운트).
+// 작성일/조회는 우선순위 낮음 → mobile drop.
+import Link from 'next/link';
+import DataTable, { DataTableColumn } from '@/components/table/DataTable';
+import Post from '@/types/forum/post';
+import { formatDate } from '@/lib/utils/client';
+import { formatNumber } from '@/lib/utils/stock';
+
+export function buildPostColumns(): DataTableColumn<Post>[] {
+	return [
+		{
+			key: 'subject',
+			header: '제목',
+			align: 'left',
+			priority: 'high',
+			cellClassName: 'data-table__cell--name',
+			cell: (row) => (
+				<Link href={`/post/${row.id}`} className='post-cell'>
+					{row.boardPrefix && (
+						<span className='post-cell__prefix'>[{row.boardPrefix.name}]</span>
+					)}
+					<span className='post-cell__subject'>{row.subject}</span>
+					{row.comments > 0 && (
+						<span className='post-cell__comments'>[{row.comments}]</span>
+					)}
+				</Link>
+			)
+		},
+		{
+			key: 'author',
+			header: '작성자',
+			align: 'left',
+			priority: 'medium',
+			cell: (row) => row.name || row.sid || '-'
+		},
+		{
+			key: 'createdAt',
+			header: '작성일',
+			align: 'right',
+			priority: 'low',
+			cell: (row) => formatDate(row.createdAt)
+		},
+		{
+			key: 'views',
+			header: '조회',
+			align: 'right',
+			priority: 'low',
+			cell: (row) => formatNumber(row.views)
+		},
+		{
+			key: 'likes',
+			header: '추천',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => formatNumber(row.likes)
+		}
+	];
+}
+
+interface PostDataTableProps {
+	rows: Post[];
+	caption?: string;
+	emptyMessage?: string;
+}
+
+// 게시글 목록 편의 래퍼 — 기본 열 구성으로 DataTable 렌더
+export default function PostDataTable({ rows, caption, emptyMessage }: PostDataTableProps)
+{
+	return (
+		<DataTable<Post>
+			caption={caption ?? '게시글 목록 — 제목, 작성자, 작성일, 조회수, 추천수'}
+			columns={buildPostColumns()}
+			rows={rows}
+			rowKey={(row) => row.id}
+			emptyMessage={emptyMessage ?? '등록된 글이 없습니다.'}
+		/>
+	);
+}

+ 24 - 0
components/table/PriceChange.tsx

@@ -0,0 +1,24 @@
+'use client';
+
+// D6 §5.1 — 등락 표시: 색(--price-*) + 부호 기호(▲▼—) 병행 (색맹 대응).
+// 등락에 포인트 컬러(--az-accent) 사용 금지.
+import { changeDirection, changeSymbol, formatChangeRate } from '@/lib/utils/stock';
+
+interface Props {
+	rate: number|null|undefined;
+	// true 면 등락률(%) 대신 등락액(원)도 표기 가능 — 여기선 rate 만 다룸
+	showSign?: boolean;
+	className?: string;
+}
+
+export default function PriceChange({ rate, className }: Props)
+{
+	const direction = changeDirection(rate);
+
+	return (
+		<span className={`price-change price-change--${direction}${className ? ` ${className}` : ''}`}>
+			<span className='price-change__symbol' aria-hidden='true'>{changeSymbol(direction)}</span>
+			<span className='price-change__value'>{formatChangeRate(rate)}</span>
+		</span>
+	);
+}

+ 83 - 0
components/table/StockRow.tsx

@@ -0,0 +1,83 @@
+'use client';
+
+// D6 §5.1 — 종목행(StockRow) 열 구성 빌더 + StockDataTable 편의 래퍼.
+// 한 줄 완결: 종목명+코드 | 현재가 | 등락률(색+부호) | 시가총액 | 시장.
+// 거래량/시총 등 우선순위 낮은 열은 mobile drop (data-priority='low').
+import Link from 'next/link';
+import DataTable, { DataTableColumn } from '@/components/table/DataTable';
+import PriceChange from '@/components/table/PriceChange';
+import { StockListRow } from '@/types/stock';
+import { formatKrwCompact, formatNumber, tradingStatusLabel } from '@/lib/utils/stock';
+
+export function buildStockColumns(): DataTableColumn<StockListRow>[] {
+	return [
+		{
+			key: 'name',
+			header: '종목명',
+			align: 'left',
+			priority: 'high',
+			cellClassName: 'data-table__cell--name',
+			cell: (row) => {
+				const status = tradingStatusLabel(row.tradingStatus);
+
+				return (
+					<Link href={`/stock/${row.code}`} className='stock-cell'>
+						<span className='stock-cell__name'>{row.name}</span>
+						<span className='stock-cell__code'>{row.code}</span>
+						{status && (
+							<span className={`stock-badge stock-badge--${row.tradingStatus.toLowerCase()}`}>{status}</span>
+						)}
+					</Link>
+				);
+			}
+		},
+		{
+			key: 'price',
+			header: <>현재가 <small>(전일종가)</small></>,
+			align: 'right',
+			priority: 'high',
+			cell: (row) => formatNumber(row.closePrice)
+		},
+		{
+			key: 'change',
+			header: '등락률',
+			align: 'right',
+			priority: 'high',
+			cell: (row) => <PriceChange rate={row.changeRate} />
+		},
+		{
+			key: 'cap',
+			header: '시가총액',
+			align: 'right',
+			priority: 'low',
+			cell: (row) => formatKrwCompact(row.marketCap)
+		},
+		{
+			key: 'market',
+			header: '시장',
+			align: 'center',
+			priority: 'medium',
+			cell: (row) => <span className={`stock-badge stock-badge--${row.market.toLowerCase()}`}>{row.market}</span>
+		}
+	];
+}
+
+interface StockDataTableProps {
+	rows: StockListRow[];
+	caption?: string;
+	emptyMessage?: string;
+}
+
+// 종목 목록 편의 래퍼 — 기본 열 구성으로 DataTable 렌더
+export default function StockDataTable({ rows, caption, emptyMessage }: StockDataTableProps)
+{
+	return (
+		<DataTable<StockListRow>
+			caption={caption ?? '종목 목록 — 종목명, 현재가(전일 종가), 등락률, 시가총액, 시장'}
+			columns={buildStockColumns()}
+			rows={rows}
+			rowKey={(row) => row.code}
+			emptyMessage={emptyMessage}
+		/>
+	);
+}

+ 154 - 0
components/ui/command.tsx

@@ -0,0 +1,154 @@
+"use client"
+
+// shadcn Command (cmdk 기반) — D6 §5.2 Ctrl+K 커맨드 팔레트용
+// 이 프로젝트는 Radix 대신 @base-ui/react 를 쓰지만 cmdk 는 독립 패키지라 그대로 사용 가능.
+// 팔레트 Dialog 는 기존 components/ui/dialog(@base-ui) 를 재사용해 포커스 트랩·aria-modal 확보.
+import * as React from "react"
+import { Command as CommandPrimitive } from "cmdk"
+import { Search } from "lucide-react"
+
+import { cn } from "@/lib/utils/client"
+import { Dialog, DialogContent } from "@/components/ui/dialog"
+
+const Command = React.forwardRef<
+	React.ElementRef<typeof CommandPrimitive>,
+	React.ComponentPropsWithoutRef<typeof CommandPrimitive>
+>(({ className, ...props }, ref) => (
+	<CommandPrimitive
+		ref={ref}
+		className={cn(
+			"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
+			className
+		)}
+		{...props}
+	/>
+))
+Command.displayName = CommandPrimitive.displayName
+
+type CommandDialogProps = React.ComponentPropsWithoutRef<typeof CommandPrimitive> & {
+	open: boolean
+	onOpenChange: (_open: boolean) => void
+	label?: string
+}
+
+const CommandDialog = ({ open, onOpenChange, label, children, ...props }: CommandDialogProps) => {
+	return (
+		<Dialog open={open} onOpenChange={onOpenChange}>
+			<DialogContent className="overflow-hidden p-0">
+				<Command
+					label={label}
+					className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
+					{...props}
+				>
+					{children}
+				</Command>
+			</DialogContent>
+		</Dialog>
+	)
+}
+
+const CommandInput = React.forwardRef<
+	React.ElementRef<typeof CommandPrimitive.Input>,
+	React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
+>(({ className, ...props }, ref) => (
+	<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
+		<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
+		<CommandPrimitive.Input
+			ref={ref}
+			className={cn(
+				"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
+				className
+			)}
+			{...props}
+		/>
+	</div>
+))
+CommandInput.displayName = CommandPrimitive.Input.displayName
+
+const CommandList = React.forwardRef<
+	React.ElementRef<typeof CommandPrimitive.List>,
+	React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
+>(({ className, ...props }, ref) => (
+	<CommandPrimitive.List
+		ref={ref}
+		className={cn("max-h-[360px] overflow-y-auto overflow-x-hidden", className)}
+		{...props}
+	/>
+))
+CommandList.displayName = CommandPrimitive.List.displayName
+
+const CommandEmpty = React.forwardRef<
+	React.ElementRef<typeof CommandPrimitive.Empty>,
+	React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
+>((props, ref) => (
+	<CommandPrimitive.Empty
+		ref={ref}
+		className="py-6 text-center text-sm text-muted-foreground"
+		{...props}
+	/>
+))
+CommandEmpty.displayName = CommandPrimitive.Empty.displayName
+
+const CommandGroup = React.forwardRef<
+	React.ElementRef<typeof CommandPrimitive.Group>,
+	React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
+>(({ className, ...props }, ref) => (
+	<CommandPrimitive.Group
+		ref={ref}
+		className={cn(
+			"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
+			className
+		)}
+		{...props}
+	/>
+))
+CommandGroup.displayName = CommandPrimitive.Group.displayName
+
+const CommandSeparator = React.forwardRef<
+	React.ElementRef<typeof CommandPrimitive.Separator>,
+	React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+	<CommandPrimitive.Separator
+		ref={ref}
+		className={cn("-mx-1 h-px bg-border", className)}
+		{...props}
+	/>
+))
+CommandSeparator.displayName = CommandPrimitive.Separator.displayName
+
+const CommandItem = React.forwardRef<
+	React.ElementRef<typeof CommandPrimitive.Item>,
+	React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
+>(({ className, ...props }, ref) => (
+	<CommandPrimitive.Item
+		ref={ref}
+		className={cn(
+			"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+			className
+		)}
+		{...props}
+	/>
+))
+CommandItem.displayName = CommandPrimitive.Item.displayName
+
+const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
+	return (
+		<span
+			className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
+			{...props}
+		/>
+	)
+}
+CommandShortcut.displayName = "CommandShortcut"
+
+export {
+	Command,
+	CommandDialog,
+	CommandInput,
+	CommandList,
+	CommandEmpty,
+	CommandGroup,
+	CommandItem,
+	CommandShortcut,
+	CommandSeparator,
+}

+ 17 - 0
package-lock.json

@@ -23,6 +23,7 @@
                 "animate.css": "^4.1.1",
                 "animate.css": "^4.1.1",
                 "class-variance-authority": "^0.7.1",
                 "class-variance-authority": "^0.7.1",
                 "clsx": "^2.1.1",
                 "clsx": "^2.1.1",
+                "cmdk": "^1.1.1",
                 "embla-carousel-autoplay": "^8.6.0",
                 "embla-carousel-autoplay": "^8.6.0",
                 "embla-carousel-react": "^8.6.0",
                 "embla-carousel-react": "^8.6.0",
                 "emoji-picker-react": "^4.12.2",
                 "emoji-picker-react": "^4.12.2",
@@ -14874,6 +14875,22 @@
                 "node": ">=6"
                 "node": ">=6"
             }
             }
         },
         },
+        "node_modules/cmdk": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
+            "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
+            "license": "MIT",
+            "dependencies": {
+                "@radix-ui/react-compose-refs": "^1.1.1",
+                "@radix-ui/react-dialog": "^1.1.6",
+                "@radix-ui/react-id": "^1.1.0",
+                "@radix-ui/react-primitive": "^2.0.2"
+            },
+            "peerDependencies": {
+                "react": "^18 || ^19 || ^19.0.0-rc",
+                "react-dom": "^18 || ^19 || ^19.0.0-rc"
+            }
+        },
         "node_modules/color-convert": {
         "node_modules/color-convert": {
             "version": "2.0.1",
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
             "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",

+ 1 - 0
package.json

@@ -24,6 +24,7 @@
         "animate.css": "^4.1.1",
         "animate.css": "^4.1.1",
         "class-variance-authority": "^0.7.1",
         "class-variance-authority": "^0.7.1",
         "clsx": "^2.1.1",
         "clsx": "^2.1.1",
+        "cmdk": "^1.1.1",
         "embla-carousel-autoplay": "^8.6.0",
         "embla-carousel-autoplay": "^8.6.0",
         "embla-carousel-react": "^8.6.0",
         "embla-carousel-react": "^8.6.0",
         "emoji-picker-react": "^4.12.2",
         "emoji-picker-react": "^4.12.2",