Files
sam-react-prod/src/components/layout/CommandMenuSearch.tsx
유병철 07374c826c refactor(WEB): claudedocs 재정리 및 AuthContext/Zustand/유틸 코드 개선
- claudedocs 폴더 구조 재정리: archive/sessions, guides/migration·mobile·universal-list, refactoring 분류
- 오래된 세션 컨텍스트/체크리스트 문서 정리 (아카이브 이동 또는 삭제)
- AuthContext → authStore(Zustand) 전환 시작, RootProvider 간소화
- GenericCRUDDialog 공통 다이얼로그 컴포넌트 추가
- PermissionDialog 삭제 → GenericCRUDDialog로 대체
- RankDialog/TitleDialog GenericCRUDDialog 기반으로 리팩토링
- toast-utils.ts 삭제 (미사용)
- fileDownload.ts 개선, excel-download.ts 정리
- menuStore/themeStore Zustand 셀렉터 최적화
- useColumnSettings/useTableColumnStore 기능 보강
- 세금계산서/견적/작업자화면/결재 등 소규모 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:17:13 +09:00

199 lines
5.6 KiB
TypeScript

'use client';
import { useEffect, useState, useMemo, useCallback, useImperativeHandle, forwardRef } from 'react';
import { useRouter } from 'next/navigation';
import { useMenuItems, type MenuItem } from '@/stores/menuStore';
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();
const menuItems = useMenuItems();
// 외부에서 제어할 수 있도록 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;