메뉴 사이드바 구조처리리

This commit is contained in:
pgb 2024-12-19 11:45:22 +09:00
parent f82393c9bd
commit 7ece0dd180

View File

@ -11,9 +11,6 @@ import { api } from "@/lib/api";
import { import {
Building2, Building2,
Box, Box,
DollarSign,
Users,
Sliders,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Calendar, Calendar,
@ -42,100 +39,138 @@ interface DBApiResponse {
} }
} }
interface MenuItem { interface ProcessedMenuItem {
id: string; // id 추가 id: string;
title: string; title: string;
items: { href?: string;
id: string; // id 추가 icon: React.ComponentType<{ className?: string }>;
title: string; items?: ProcessedMenuItem[];
href: string;
icon: React.ComponentType<{ className?: string }>;
}[];
} }
interface MenuItemProps { interface MenuItemProps {
item: MenuItem; item: ProcessedMenuItem;
isOpen: boolean; depth: number;
onToggle: () => void; openSections: { [key: string]: boolean };
onToggle: (id: string) => void;
pathname: string; pathname: string;
} }
const MenuItemComponent: React.FC<MenuItemProps> = ({ const MenuItemComponent: React.FC<MenuItemProps> = ({
item, item,
isOpen, depth,
onToggle, openSections,
pathname, onToggle,
}) => { pathname,
const IconComponent = Box; }) => {
const hasChildren = item.items && item.items.length > 0;
return ( const isOpen = openSections[item.id];
<div className="mb-1"> const IconComponent = item.icon || Box;
<button
onClick={onToggle} // 들여쓰기 계산
className={cn( const getIndentClass = (depth: number) => {
"w-full flex items-center px-3 py-2 text-sm font-medium rounded-md", if (depth === 0) return "px-3";
"transition-colors duration-150", if (depth === 1) return "px-3 pl-7";
"hover:bg-gray-100", return "px-3 pl-11";
isOpen ? "text-blue-600 bg-blue-50" : "text-gray-700" };
const itemContent = (
<>
<IconComponent className="h-4 w-4 mr-2 flex-shrink-0 text-gray-600" />
<span className="flex-1 text-left text-sm">{item.title}</span>
{hasChildren && (
<div className="flex items-center">
{isOpen
? <ChevronDown className="h-4 w-4 text-gray-400" />
: <ChevronRight className="h-4 w-4 text-gray-400" />
}
</div>
)} )}
> </>
<IconComponent className="h-5 w-5 mr-2" /> );
<span className="flex-1 text-left">{item.title}</span>
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />} return (
</button> <div>
{item.href ? (
{isOpen && ( <Link
<div className="ml-9 mt-1 space-y-1"> href={item.href}
{item.items.map((subItem) => ( className={cn(
<Link "flex items-center h-8",
key={subItem.id} getIndentClass(depth),
href={subItem.href} "transition-colors duration-150",
className={cn( pathname === item.href
"flex items-center px-3 py-2 text-sm rounded-md", ? "bg-blue-50 text-blue-600"
"transition-colors duration-150", : cn(
pathname === subItem.href "text-gray-700 hover:bg-gray-50",
? "bg-blue-50 text-blue-600 font-medium" depth > 0 && "bg-gray-50/50"
: "text-gray-600 hover:bg-gray-50" )
)} )}
> >
<Box className="h-4 w-4 mr-2 flex-shrink-0" /> {itemContent}
{subItem.title} </Link>
</Link> ) : (
))} <button
</div> onClick={() => onToggle(item.id)}
)} className={cn(
</div> "w-full flex items-center h-8",
); getIndentClass(depth),
}; "transition-colors duration-150",
function processMenuItems(responseData: DBApiResponse, role: string): MenuItem[] { isOpen
? "bg-blue-50 text-blue-600"
: cn(
"text-gray-700 hover:bg-gray-50",
depth > 0 && "bg-gray-50/50"
)
)}
>
{itemContent}
</button>
)}
{hasChildren && isOpen && (
<div>
{item.items?.map((subItem) => (
<MenuItemComponent
key={subItem.id}
item={subItem}
depth={depth + 1}
openSections={openSections}
onToggle={onToggle}
pathname={pathname}
/>
))}
</div>
)}
</div>
);
};
function processMenuItems(responseData: DBApiResponse, role: string): ProcessedMenuItem[] {
if (!responseData?.data?.data) { if (!responseData?.data?.data) {
return []; return [];
} }
const menuData = responseData.data.data; const menuData = responseData.data.data;
// 트리 구조로 된 메뉴 데이터로부터 MenuItem[] 생성 const buildMenuItem = (menu: DBMenuItem): ProcessedMenuItem => {
const buildMenuItem = (menu: DBMenuItem): MenuItem => {
const children = menu.children || [];
// 자식 메뉴들을 seq 기준으로 정렬 // 자식 메뉴들을 seq 기준으로 정렬
const sortedChildren = [...children].sort((a, b) => { const sortedChildren = menu.children
const seqA = parseInt(a.seq) || 0; ? [...menu.children].sort((a, b) => {
const seqB = parseInt(b.seq) || 0; const seqA = parseInt(a.seq) || 0;
return seqA - seqB; const seqB = parseInt(b.seq) || 0;
}); return seqA - seqB;
})
: [];
// 메뉴 아이템 생성 // 재귀적으로 자식 메뉴들을 처리
const result = { const processedChildren = sortedChildren.map(buildMenuItem);
return {
id: menu.id, id: menu.id,
title: menu.menu_name_kor, title: menu.menu_name_kor,
items: sortedChildren.map(child => ({ href: menu.menu_url || undefined,
id: child.id, icon: Box,
title: child.menu_name_kor, ...(processedChildren.length > 0 && { items: processedChildren }),
href: child.menu_url || '#',
icon: Box
}))
}; };
return result;
}; };
// 최상위 메뉴들을 찾고 권한 체크 후 seq로 정렬 // 최상위 메뉴들을 찾고 권한 체크 후 seq로 정렬
@ -151,6 +186,7 @@ function processMenuItems(responseData: DBApiResponse, role: string): MenuItem[]
}) })
.map(buildMenuItem); .map(buildMenuItem);
} }
export function SideNav() { export function SideNav() {
const pathname = usePathname(); const pathname = usePathname();
const { user } = useAuth(); const { user } = useAuth();
@ -170,36 +206,40 @@ export function SideNav() {
const [openSections, setOpenSections] = useState<{ [key: string]: boolean }>( const [openSections, setOpenSections] = useState<{ [key: string]: boolean }>(
() => { () => {
return menuItems.reduce((acc: { [key: string]: boolean }, item) => { const findOpenSections = (items: ProcessedMenuItem[], path: string): string[] => {
if (item.items?.some((subItem) => subItem.href === pathname)) { const openSections: string[] = [];
acc[item.title] = true;
} const findPath = (items: ProcessedMenuItem[]): boolean => {
return acc; for (const item of items) {
}, {}); if (item.href === path) {
return true;
}
if (item.items) {
if (findPath(item.items)) {
openSections.push(item.id);
return true;
}
}
}
return false;
};
findPath(items);
return openSections;
};
const openIds = findOpenSections(menuItems, pathname);
return openIds.reduce((acc, id) => ({ ...acc, [id]: true }), {});
} }
); );
const toggleSection = (title: string) => { const toggleSection = (id: string) => {
setOpenSections((prev) => ({ setOpenSections((prev) => ({
...prev, ...prev,
[title]: !prev[title], [id]: !prev[id],
})); }));
}; };
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ko-KR", {
year: "numeric",
month: "long",
day: "numeric",
});
};
const formatBusinessNumber = (number: string) => {
if (!number) return "";
const cleaned = number.replace(/[^0-9]/g, "");
return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 5)}-${cleaned.slice(5)}`;
};
return ( return (
<nav className="w-64 bg-white border-r border-gray-200 h-screen flex flex-col"> <nav className="w-64 bg-white border-r border-gray-200 h-screen flex flex-col">
<div <div
@ -207,46 +247,44 @@ export function SideNav() {
onClick={() => (window.location.href = "/dashboard/overview")} onClick={() => (window.location.href = "/dashboard/overview")}
> >
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Gauge className="h-8 w-6 text-blue-600" /> <Gauge className="h-5 w-5 text-blue-600" />
<h1 className="text-xl font-semibold text-gray-900">PLM</h1> <h1 className="text-lg text-gray-900">PLM</h1>
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto p-3"> <div className="flex-1 overflow-y-auto">
{menuItems.map((item) => ( {menuItems.map((item) => (
<MenuItemComponent <MenuItemComponent
key={item.id} // title 대신 id 사용 key={item.id}
item={item} item={item}
isOpen={openSections[item.title]} depth={0}
onToggle={() => toggleSection(item.title)} openSections={openSections}
pathname={pathname} onToggle={toggleSection}
/> pathname={pathname}
))} />
</div> ))}
</div>
<div className="mt-auto border-t border-gray-200 p-4"> <div className="mt-auto border-t border-gray-200 p-4">
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<Building2 className="h-5 w-5 text-gray-500 mt-0.5" /> <Building2 className="h-4 w-4 text-gray-500 mt-0.5" />
<div className="flex-1"> <div className="flex-1">
<h3 className="text-sm font-medium text-gray-900"> <h3 className="text-sm text-gray-900">
{user?.companyName} - {user?.branchName} {user?.companyName} - {user?.branchName}
</h3> </h3>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
: {formatBusinessNumber(user?.businessNumber || "")} : {user?.businessNumber || ""}
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Calendar className="h-5 w-5 text-gray-500" /> <Calendar className="h-4 w-4 text-gray-500" />
<div className="flex-1"> <div className="flex-1">
<p className="text-xs text-gray-500"> :</p> <p className="text-xs text-gray-500"> :</p>
<p className="text-sm font-medium text-gray-900"> <p className="text-sm text-gray-900">
{user?.contractEndDate {user?.contractEndDate || "정보 없음"}
? formatDate(user.contractEndDate)
: "정보 없음"}
</p> </p>
</div> </div>
</div> </div>