2026-01-26 15:07:10 +09:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useEffect, useState, useMemo, useCallback, useImperativeHandle, forwardRef } from 'react';
|
|
|
|
|
import { useRouter } from 'next/navigation';
|
2026-02-23 17:17:13 +09:00
|
|
|
import { useMenuItems, type MenuItem } from '@/stores/menuStore';
|
2026-01-26 15:07:10 +09:00
|
|
|
import {
|
|
|
|
|
CommandDialog,
|
|
|
|
|
CommandInput,
|
|
|
|
|
CommandList,
|
|
|
|
|
CommandEmpty,
|
|
|
|
|
CommandGroup,
|
|
|
|
|
CommandItem,
|
|
|
|
|
} from '@/components/ui/command';
|
|
|
|
|
import { Folder, ChevronRight } from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
// 평탄화된 메뉴 아이템 타입
|
|
|
|
|
interface FlatMenuItem {
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
|
|
|
|
path: string;
|
|
|
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
|
|
|
breadcrumb: string[]; // 부모 메뉴 경로 (예: ['판매관리', '거래처관리'])
|
|
|
|
|
depth: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 외부에서 제어 가능한 ref 타입
|
|
|
|
|
export interface CommandMenuSearchRef {
|
|
|
|
|
open: () => void;
|
|
|
|
|
close: () => void;
|
|
|
|
|
toggle: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 메뉴 트리를 평탄화하는 함수
|
|
|
|
|
function flattenMenuItems(
|
|
|
|
|
items: MenuItem[],
|
|
|
|
|
parentBreadcrumb: string[] = []
|
|
|
|
|
): FlatMenuItem[] {
|
|
|
|
|
const result: FlatMenuItem[] = [];
|
|
|
|
|
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
const currentBreadcrumb = [...parentBreadcrumb, item.label];
|
|
|
|
|
|
|
|
|
|
// 실제 경로가 있는 메뉴만 추가 (# 제외)
|
|
|
|
|
if (item.path && item.path !== '#') {
|
|
|
|
|
result.push({
|
|
|
|
|
id: item.id,
|
|
|
|
|
label: item.label,
|
|
|
|
|
path: item.path,
|
|
|
|
|
icon: item.icon || Folder,
|
|
|
|
|
breadcrumb: currentBreadcrumb,
|
|
|
|
|
depth: currentBreadcrumb.length,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 자식 메뉴 재귀 처리
|
|
|
|
|
if (item.children && item.children.length > 0) {
|
|
|
|
|
result.push(...flattenMenuItems(item.children, currentBreadcrumb));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const CommandMenuSearch = forwardRef<CommandMenuSearchRef>((_, ref) => {
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
const [search, setSearch] = useState('');
|
|
|
|
|
const router = useRouter();
|
2026-02-23 17:17:13 +09:00
|
|
|
const menuItems = useMenuItems();
|
2026-01-26 15:07:10 +09:00
|
|
|
|
|
|
|
|
// 외부에서 제어할 수 있도록 ref 노출
|
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
|
|
|
open: () => setOpen(true),
|
|
|
|
|
close: () => setOpen(false),
|
|
|
|
|
toggle: () => setOpen((prev) => !prev),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// 평탄화된 메뉴 목록 (메모이제이션)
|
|
|
|
|
const flatMenuItems = useMemo(() => {
|
|
|
|
|
return flattenMenuItems(menuItems);
|
|
|
|
|
}, [menuItems]);
|
|
|
|
|
|
|
|
|
|
// 검색 필터링 (한글 초성 검색 지원)
|
|
|
|
|
const filteredItems = useMemo(() => {
|
|
|
|
|
if (!search.trim()) {
|
|
|
|
|
return flatMenuItems;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const searchLower = search.toLowerCase();
|
|
|
|
|
|
|
|
|
|
return flatMenuItems.filter((item) => {
|
|
|
|
|
// 메뉴 이름으로 검색
|
|
|
|
|
if (item.label.toLowerCase().includes(searchLower)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// breadcrumb 전체 경로로 검색
|
|
|
|
|
const fullPath = item.breadcrumb.join(' ').toLowerCase();
|
|
|
|
|
if (fullPath.includes(searchLower)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 경로로 검색
|
|
|
|
|
if (item.path.toLowerCase().includes(searchLower)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
});
|
|
|
|
|
}, [flatMenuItems, search]);
|
|
|
|
|
|
|
|
|
|
// 메뉴 선택 핸들러
|
|
|
|
|
const handleSelect = useCallback(
|
|
|
|
|
(item: FlatMenuItem) => {
|
|
|
|
|
setOpen(false);
|
|
|
|
|
setSearch('');
|
|
|
|
|
router.push(item.path);
|
|
|
|
|
},
|
|
|
|
|
[router]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 키보드 단축키 (Ctrl+K / Cmd+K)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
|
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setOpen((prev) => !prev);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
|
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 다이얼로그 닫힐 때 검색어 초기화
|
|
|
|
|
const handleOpenChange = (isOpen: boolean) => {
|
|
|
|
|
setOpen(isOpen);
|
|
|
|
|
if (!isOpen) {
|
|
|
|
|
setSearch('');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<CommandDialog
|
|
|
|
|
open={open}
|
|
|
|
|
onOpenChange={handleOpenChange}
|
|
|
|
|
title="메뉴 검색"
|
|
|
|
|
description="메뉴 이름이나 경로를 입력하세요"
|
|
|
|
|
>
|
|
|
|
|
<CommandInput
|
|
|
|
|
placeholder="메뉴 검색... (예: 거래처, 품목, 단가)"
|
|
|
|
|
value={search}
|
|
|
|
|
onValueChange={setSearch}
|
|
|
|
|
/>
|
|
|
|
|
<CommandList>
|
|
|
|
|
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
|
|
|
|
<CommandGroup heading="메뉴">
|
|
|
|
|
{filteredItems.map((item) => {
|
|
|
|
|
const IconComponent = item.icon;
|
|
|
|
|
return (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={item.id}
|
|
|
|
|
value={item.breadcrumb.join(' ')}
|
|
|
|
|
onSelect={() => handleSelect(item)}
|
|
|
|
|
className="flex items-center gap-2 cursor-pointer"
|
|
|
|
|
>
|
|
|
|
|
<IconComponent className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
|
|
|
|
<div className="flex items-center gap-1 flex-1 min-w-0">
|
|
|
|
|
{item.breadcrumb.map((crumb, index) => (
|
|
|
|
|
<span key={index} className="flex items-center gap-1">
|
|
|
|
|
{index > 0 && (
|
|
|
|
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
|
|
|
|
)}
|
|
|
|
|
<span
|
|
|
|
|
className={
|
|
|
|
|
index === item.breadcrumb.length - 1
|
|
|
|
|
? 'font-medium text-foreground'
|
|
|
|
|
: 'text-muted-foreground text-sm'
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{crumb}
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-xs text-muted-foreground ml-auto hidden sm:block">
|
|
|
|
|
{item.path}
|
|
|
|
|
</span>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</CommandDialog>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
CommandMenuSearch.displayName = 'CommandMenuSearch';
|
|
|
|
|
|
|
|
|
|
export default CommandMenuSearch;
|