Rekursif nav items but not finished yet
This commit is contained in:
parent
d14cd33ef3
commit
bf12ded925
|
@ -1,4 +1,3 @@
|
||||||
import { AppSidebar } from "@/components/app-sidebar";
|
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { checkSession } from "@/actions/auth/session";
|
|
||||||
import { AppSidebar } from "@/components/app-sidebar";
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { AppSidebar } from "@/components/app-sidebar";
|
|
||||||
import { MapboxMap } from "@/components/map/mapbox-view";
|
import { MapboxMap } from "@/components/map/mapbox-view";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
|
|
|
@ -1,20 +1,28 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
AudioWaveform,
|
|
||||||
Command,
|
|
||||||
Frame,
|
|
||||||
GalleryVerticalEnd,
|
|
||||||
Loader2,
|
|
||||||
SquareTerminal,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { NavMain } from "@/components/nav-main";
|
import { NavMain } from "@/components/nav-main";
|
||||||
import { NavProjects } from "@/components/nav-projects";
|
import { NavProjects } from "@/components/nav-projects";
|
||||||
import { NavUser } from "@/components/nav-user";
|
import { NavUser } from "@/components/nav-user";
|
||||||
import { TeamSwitcher } from "@/components/team-switcher";
|
import { TeamSwitcher } from "@/components/team-switcher";
|
||||||
|
|
||||||
|
import {
|
||||||
|
IconHome,
|
||||||
|
IconAlertTriangle,
|
||||||
|
IconSettings,
|
||||||
|
IconMap,
|
||||||
|
IconDatabase,
|
||||||
|
IconUsers,
|
||||||
|
IconMessageCircle,
|
||||||
|
IconMenu2,
|
||||||
|
IconAlbum,
|
||||||
|
IconMusicBolt,
|
||||||
|
IconCommand,
|
||||||
|
IconFrame,
|
||||||
|
IconChartPie,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
|
@ -22,18 +30,9 @@ import {
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarRail,
|
SidebarRail,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import {
|
|
||||||
NavItemsGet,
|
|
||||||
NavSubItems,
|
|
||||||
} from "@/src/applications/entities/models/nav-items.model";
|
|
||||||
import { getNavItems } from "@/actions/dashboard/nav-items";
|
|
||||||
import DynamicIcon, { DynamicIconProps } from "./dynamic-icon";
|
|
||||||
|
|
||||||
// 🔧 Konstanta untuk localStorage key
|
// This is sample data.
|
||||||
const NAV_ITEMS_STORAGE_KEY = "navItemsData";
|
const data = {
|
||||||
|
|
||||||
// 🎨 Data fallback
|
|
||||||
const fallbackData = {
|
|
||||||
user: {
|
user: {
|
||||||
name: "shadcn",
|
name: "shadcn",
|
||||||
email: "m@example.com",
|
email: "m@example.com",
|
||||||
|
@ -42,160 +41,258 @@ const fallbackData = {
|
||||||
teams: [
|
teams: [
|
||||||
{
|
{
|
||||||
name: "Acme Inc",
|
name: "Acme Inc",
|
||||||
logo: GalleryVerticalEnd,
|
logo: IconAlbum,
|
||||||
plan: "Enterprise",
|
plan: "Enterprise",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Acme Corp.",
|
name: "Acme Corp.",
|
||||||
logo: AudioWaveform,
|
logo: IconMusicBolt,
|
||||||
plan: "Startup",
|
plan: "Startup",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Evil Corp.",
|
name: "Evil Corp.",
|
||||||
logo: Command,
|
logo: IconCommand,
|
||||||
plan: "Free",
|
plan: "Free",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
navMain: [
|
navMain: [
|
||||||
{
|
{
|
||||||
title: "Playground",
|
title: "Dashboard",
|
||||||
url: "#",
|
url: "/dashboard",
|
||||||
icon: SquareTerminal,
|
slug: "dashboard",
|
||||||
|
orderSeq: 1,
|
||||||
|
icon: IconHome,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
items: [
|
subItems: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Crime Management",
|
||||||
|
url: "/crime-management",
|
||||||
|
slug: "crime-management",
|
||||||
|
orderSeq: 2,
|
||||||
|
icon: IconAlertTriangle,
|
||||||
|
isActive: true,
|
||||||
|
subItems: [
|
||||||
{
|
{
|
||||||
title: "History",
|
title: "Crime Overview",
|
||||||
url: "#",
|
url: "/crime-management/crime-overview",
|
||||||
|
slug: "crime-overview",
|
||||||
|
icon: IconAlertTriangle,
|
||||||
|
orderSeq: 1,
|
||||||
|
isActive: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Starred",
|
title: "Crime Categories",
|
||||||
url: "#",
|
url: "/crime-management/crime-categories",
|
||||||
|
slug: "crime-categories",
|
||||||
|
icon: IconSettings,
|
||||||
|
orderSeq: 2,
|
||||||
|
isActive: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Settings",
|
title: "Cases",
|
||||||
url: "#",
|
url: "/crime-management/crime-cases",
|
||||||
|
slug: "crime-cases",
|
||||||
|
icon: IconAlertTriangle,
|
||||||
|
orderSeq: 3,
|
||||||
|
isActive: true,
|
||||||
|
subSubItems: [
|
||||||
|
{
|
||||||
|
title: "New Case",
|
||||||
|
url: "/crime-management/crime-cases/case-new",
|
||||||
|
slug: "new-case",
|
||||||
|
icon: IconAlertTriangle,
|
||||||
|
orderSeq: 1,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Active Cases",
|
||||||
|
url: "/crime-management/crime-cases/case-active",
|
||||||
|
slug: "active-cases",
|
||||||
|
icon: IconAlertTriangle,
|
||||||
|
orderSeq: 2,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Resolved Cases",
|
||||||
|
url: "/crime-management/crime-cases/case-closed",
|
||||||
|
slug: "resolved-cases",
|
||||||
|
icon: IconAlertTriangle,
|
||||||
|
orderSeq: 3,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Geographic Data",
|
||||||
|
url: "/geographic-data",
|
||||||
|
slug: "geographic-data",
|
||||||
|
orderSeq: 3,
|
||||||
|
icon: IconMap,
|
||||||
|
isActive: true,
|
||||||
|
subItems: [
|
||||||
|
{
|
||||||
|
title: "Locations",
|
||||||
|
url: "/geographic-data/locations",
|
||||||
|
slug: "locations",
|
||||||
|
icon: IconMap,
|
||||||
|
orderSeq: 1,
|
||||||
|
isActive: true,
|
||||||
|
subSubItems: [
|
||||||
|
{
|
||||||
|
title: "Cities",
|
||||||
|
url: "/geographic-data/cities",
|
||||||
|
slug: "cities",
|
||||||
|
icon: IconMap,
|
||||||
|
orderSeq: 1,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Districts",
|
||||||
|
url: "/geographic-data/districts",
|
||||||
|
slug: "districts",
|
||||||
|
icon: IconMap,
|
||||||
|
orderSeq: 2,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Geographic Info",
|
||||||
|
url: "/geographic-data/geographic-info",
|
||||||
|
slug: "geographic-info",
|
||||||
|
icon: IconMap,
|
||||||
|
orderSeq: 3,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Demographics",
|
||||||
|
url: "/demographics",
|
||||||
|
slug: "demographics",
|
||||||
|
orderSeq: 4,
|
||||||
|
icon: IconDatabase,
|
||||||
|
isActive: true,
|
||||||
|
subItems: [
|
||||||
|
{
|
||||||
|
title: "Demographics Data",
|
||||||
|
url: "/demographics/demographics-data",
|
||||||
|
slug: "demographics-data",
|
||||||
|
icon: IconDatabase,
|
||||||
|
orderSeq: 1,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "User Management",
|
||||||
|
url: "/user-management",
|
||||||
|
slug: "user-management",
|
||||||
|
orderSeq: 5,
|
||||||
|
icon: IconUsers,
|
||||||
|
isActive: true,
|
||||||
|
subItems: [
|
||||||
|
{
|
||||||
|
title: "Users",
|
||||||
|
url: "/user-management/users",
|
||||||
|
slug: "users",
|
||||||
|
icon: IconUsers,
|
||||||
|
orderSeq: 1,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Communication",
|
||||||
|
url: "/communication",
|
||||||
|
slug: "communication",
|
||||||
|
orderSeq: 6,
|
||||||
|
icon: IconMessageCircle,
|
||||||
|
isActive: true,
|
||||||
|
subItems: [
|
||||||
|
{
|
||||||
|
title: "Contact Messages",
|
||||||
|
url: "/communication/contact-messages",
|
||||||
|
slug: "contact-messages",
|
||||||
|
icon: IconMessageCircle,
|
||||||
|
orderSeq: 1,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Settings",
|
||||||
|
url: "/settings",
|
||||||
|
slug: "settings",
|
||||||
|
orderSeq: 7,
|
||||||
|
icon: IconSettings,
|
||||||
|
isActive: true,
|
||||||
|
subItems: [
|
||||||
|
{
|
||||||
|
title: "Navigation",
|
||||||
|
url: "/settings/navigation",
|
||||||
|
slug: "navigation",
|
||||||
|
icon: IconMenu2,
|
||||||
|
orderSeq: 1,
|
||||||
|
isActive: true,
|
||||||
|
subSubItems: [
|
||||||
|
{
|
||||||
|
title: "Nav Items",
|
||||||
|
url: "/settings/navigation/nav-items",
|
||||||
|
slug: "nav-items",
|
||||||
|
icon: IconMenu2,
|
||||||
|
orderSeq: 1,
|
||||||
|
isActive: true,
|
||||||
|
subSubItems: [
|
||||||
|
{
|
||||||
|
title: "Nav Sub Items",
|
||||||
|
url: "/settings/navigation/nav-sub-items",
|
||||||
|
slug: "nav-sub-items",
|
||||||
|
icon: IconMenu2,
|
||||||
|
orderSeq: 1,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// additional items...
|
|
||||||
],
|
],
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: "Design Engineering",
|
name: "Design Engineering",
|
||||||
url: "#",
|
url: "#",
|
||||||
icon: Frame,
|
icon: IconFrame,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sales & Marketing",
|
||||||
|
url: "#",
|
||||||
|
icon: IconChartPie,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Travel",
|
||||||
|
url: "#",
|
||||||
|
icon: IconMap,
|
||||||
},
|
},
|
||||||
// additional projects...
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const [navItems, setNavItems] = useState<NavItemsGet | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 📦 Ambil data dari localStorage
|
|
||||||
const loadNavItemsFromStorage = (): NavItemsGet | null => {
|
|
||||||
const storedData = localStorage.getItem(NAV_ITEMS_STORAGE_KEY);
|
|
||||||
return storedData ? (JSON.parse(storedData) as NavItemsGet) : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ⚡ Fungsi untuk membandingkan dua objek secara deep
|
|
||||||
const isDataEqual = (data1: NavItemsGet, data2: NavItemsGet): boolean =>
|
|
||||||
JSON.stringify(data1) === JSON.stringify(data2);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchAndSyncNavItems = async () => {
|
|
||||||
try {
|
|
||||||
const localData = loadNavItemsFromStorage();
|
|
||||||
|
|
||||||
if (localData) {
|
|
||||||
setNavItems(localData);
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getNavItems();
|
|
||||||
if (response.success && response.data) {
|
|
||||||
const fetchedData = response.data as NavItemsGet;
|
|
||||||
const currentStoredData = loadNavItemsFromStorage();
|
|
||||||
|
|
||||||
// 🔄 Jika data berbeda, update localStorage dan state
|
|
||||||
if (
|
|
||||||
currentStoredData &&
|
|
||||||
!isDataEqual(currentStoredData, fetchedData)
|
|
||||||
) {
|
|
||||||
localStorage.setItem(
|
|
||||||
NAV_ITEMS_STORAGE_KEY,
|
|
||||||
JSON.stringify(fetchedData)
|
|
||||||
);
|
|
||||||
setNavItems(fetchedData);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError(response.error || "Failed to load navigation items");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError("An unexpected error occurred while fetching navigation");
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchAndSyncNavItems();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 🎛️ Transformasi data DB ke format NavMain
|
|
||||||
const formatNavItems = React.useMemo(() => {
|
|
||||||
if (!navItems) return fallbackData.navMain;
|
|
||||||
return navItems.map((item) => ({
|
|
||||||
title: item.title,
|
|
||||||
url: item.url,
|
|
||||||
icon: item.icon,
|
|
||||||
isActive: item.isActive,
|
|
||||||
items: item.subItems.map((subItem) => ({
|
|
||||||
title: subItem.title,
|
|
||||||
url: subItem.url,
|
|
||||||
icon: subItem.icon,
|
|
||||||
isActive: subItem.isActive,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
}, [navItems]);
|
|
||||||
|
|
||||||
// 🧩 Komponen NavMain dinamis dengan ikon
|
|
||||||
const DynamicNavMain = ({ items }: { items: any[] }) => (
|
|
||||||
<NavMain
|
|
||||||
items={items.map((item) => ({
|
|
||||||
...item,
|
|
||||||
icon: () => <DynamicIcon iconName={item.icon} size={24} stroke={2} />,
|
|
||||||
items: item.items.map((subItem: NavSubItems) => ({
|
|
||||||
...subItem,
|
|
||||||
icon: () => (
|
|
||||||
<DynamicIcon iconName={subItem.icon} size={24} stroke={2} />
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon" {...props}>
|
<Sidebar collapsible="icon" {...props}>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<TeamSwitcher teams={fallbackData.teams} />
|
<TeamSwitcher teams={data.teams} />
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
{isLoading ? (
|
<NavMain items={data.navMain} />
|
||||||
<div className="flex justify-center items-center py-8">
|
<NavProjects projects={data.projects} />
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="px-4 py-2 text-sm text-destructive">{error}</div>
|
|
||||||
) : (
|
|
||||||
<DynamicNavMain items={formatNavItems} />
|
|
||||||
)}
|
|
||||||
{/* <NavProjects projects={fallbackData.projects} /> */}
|
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavUser user={fallbackData.user} />
|
<NavUser user={data.user} />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { ChevronRight, type LucideIcon } from "lucide-react"
|
import { ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible"
|
} from "@/components/ui/collapsible";
|
||||||
import {
|
import {
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupLabel,
|
SidebarGroupLabel,
|
||||||
|
@ -14,71 +14,132 @@ import {
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarMenuSub,
|
SidebarMenuSub,
|
||||||
SidebarMenuSubButton,
|
} from "@/components/ui/sidebar";
|
||||||
SidebarMenuSubItem,
|
|
||||||
} from "@/components/ui/sidebar"
|
|
||||||
|
|
||||||
import * as TablerIcons from "@tabler/icons-react";
|
import * as TablerIcons from "@tabler/icons-react";
|
||||||
|
|
||||||
export function NavMain({
|
interface SubSubItem {
|
||||||
items,
|
title: string;
|
||||||
}: {
|
url: string;
|
||||||
items: {
|
}
|
||||||
title: string;
|
|
||||||
url: string;
|
interface SubItem {
|
||||||
icon?: TablerIcons.Icon;
|
title: string;
|
||||||
isActive?: boolean;
|
url: string;
|
||||||
items?: {
|
icon?: TablerIcons.Icon;
|
||||||
title: string;
|
subSubItems?: SubSubItem[];
|
||||||
icon?: TablerIcons.Icon;
|
}
|
||||||
url: string;
|
|
||||||
}[];
|
interface NavItem {
|
||||||
}[];
|
title: string;
|
||||||
}) {
|
url: string;
|
||||||
|
icon?: TablerIcons.Icon;
|
||||||
|
isActive?: boolean;
|
||||||
|
subItems?: SubItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubSubItemComponent({ item }: { item: SubSubItem }) {
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton asChild>
|
||||||
|
<a href={item.url}>
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubItemComponent({ item }: { item: SubItem }) {
|
||||||
|
const hasSubSubItems = item.subSubItems && item.subSubItems.length > 0;
|
||||||
|
|
||||||
|
if (!hasSubSubItems) {
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton asChild>
|
||||||
|
<a href={item.url}>
|
||||||
|
{item.icon && <item.icon />}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible asChild className="group/collapsible">
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarMenuButton>
|
||||||
|
{item.icon && <item.icon />}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<SidebarMenuSub>
|
||||||
|
{item.subSubItems!.map((subSubItem) => (
|
||||||
|
<SubSubItemComponent key={subSubItem.title} item={subSubItem} />
|
||||||
|
))}
|
||||||
|
</SidebarMenuSub>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecursiveNavItem({ item, index }: { item: NavItem; index: number }) {
|
||||||
|
const hasSubItems = item.subItems && item.subItems.length > 0;
|
||||||
|
|
||||||
|
if (!hasSubItems) {
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton tooltip={item.title} asChild>
|
||||||
|
<a href={item.url}>
|
||||||
|
{item.icon && <item.icon />}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible
|
||||||
|
key={item.title}
|
||||||
|
asChild
|
||||||
|
defaultOpen={index === 1}
|
||||||
|
className="group/collapsible"
|
||||||
|
>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarMenuButton tooltip={item.title}>
|
||||||
|
{item.icon && <item.icon />}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
{hasSubItems && (
|
||||||
|
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||||
|
)}
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<SidebarMenuSub>
|
||||||
|
{item.subItems!.map((subItem) => (
|
||||||
|
<SubItemComponent key={subItem.title} item={subItem} />
|
||||||
|
))}
|
||||||
|
</SidebarMenuSub>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavMain({ items }: { items: NavItem[] }) {
|
||||||
return (
|
return (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{items.map((item) => (
|
{items.map((item, index) => (
|
||||||
<Collapsible
|
<RecursiveNavItem key={item.title} item={item} index={index} />
|
||||||
key={item.title}
|
|
||||||
asChild
|
|
||||||
defaultOpen={item.isActive}
|
|
||||||
className="group/collapsible"
|
|
||||||
>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
{item.items && item.items.length > 0 ? (
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<SidebarMenuButton tooltip={item.title}>
|
|
||||||
{item.icon && <item.icon />}
|
|
||||||
<span>{item.title}</span>
|
|
||||||
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
) : (
|
|
||||||
<SidebarMenuButton asChild tooltip={item.title}>
|
|
||||||
<a href={`/protected${item.url}`}>
|
|
||||||
{item.icon && <item.icon />}
|
|
||||||
<span>{item.title}</span>
|
|
||||||
</a>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
)}
|
|
||||||
<CollapsibleContent>
|
|
||||||
<SidebarMenuSub>
|
|
||||||
{item.items?.map((subItem) => (
|
|
||||||
<SidebarMenuSubItem key={subItem.title}>
|
|
||||||
<SidebarMenuSubButton asChild>
|
|
||||||
<a href={`/protected${subItem.url}`}>
|
|
||||||
{subItem.icon && <subItem.icon />}
|
|
||||||
<span>{subItem.title}</span>
|
|
||||||
</a>
|
|
||||||
</SidebarMenuSubButton>
|
|
||||||
</SidebarMenuSubItem>
|
|
||||||
))}
|
|
||||||
</SidebarMenuSub>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</Collapsible>
|
|
||||||
))}
|
))}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|
|
@ -25,16 +25,18 @@ import {
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
|
||||||
|
import * as TablerIcons from "@tabler/icons-react";
|
||||||
|
|
||||||
export function NavProjects({
|
export function NavProjects({
|
||||||
projects,
|
projects,
|
||||||
}: {
|
}: {
|
||||||
projects: {
|
projects: {
|
||||||
name: string
|
name: string;
|
||||||
url: string
|
url: string;
|
||||||
icon: LucideIcon
|
icon: TablerIcons.Icon;
|
||||||
}[]
|
}[];
|
||||||
}) {
|
}) {
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||||
|
@ -85,5 +87,5 @@ export function NavProjects({
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,184 +1,53 @@
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { NavigationItem } from "@/src/applications/entities/models/navigation-item.model";
|
||||||
import { useForm } from "react-hook-form";
|
import { useEffect, useState } from "react";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { NavigationItemRepositoryImpl } from "../repositories/navigation-item.repository.impl";
|
||||||
import { NavigationUseCases } from "@/src/applications/usecases/navigation-item.usecase";
|
|
||||||
import { NavigationItem } from "@prisma/client";
|
|
||||||
import {
|
|
||||||
NavigationItemFormData,
|
|
||||||
navigationItemSchema,
|
|
||||||
} from "../validators/navigation-item.validator";
|
|
||||||
import {
|
|
||||||
CreateNavigationItemDTO,
|
|
||||||
UpdateNavigationItemDTO,
|
|
||||||
} from "@/src/applications/entities/models/navigation-item.model";
|
|
||||||
import { toast } from "@/hooks/use-toast";
|
|
||||||
|
|
||||||
export const useNavigationItem = (navigationUseCases: NavigationUseCases) => {
|
export function useNavigationItem() {
|
||||||
const [items, setItems] = useState<NavigationItem[]>([]);
|
const [items, setItems] = useState<NavigationItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [formData, setFormData] = useState<NavigationItemFormData>({
|
|
||||||
title: "",
|
|
||||||
url: "",
|
|
||||||
slug: "",
|
|
||||||
icon: "",
|
|
||||||
isActive: false,
|
|
||||||
orderSeq: 0,
|
|
||||||
parentId: null,
|
|
||||||
});
|
|
||||||
const [errors, setErrors] = useState<
|
|
||||||
Partial<Record<keyof NavigationItemFormData, string>>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
const form = useForm<NavigationItemFormData>({
|
|
||||||
resolver: zodResolver(navigationItemSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadNavigationTree = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await navigationUseCases.getNavigationTree();
|
|
||||||
setItems(data);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(
|
|
||||||
err instanceof Error ? err.message : "Failed to load navigation"
|
|
||||||
);
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description:
|
|
||||||
err instanceof Error ? err.message : "Failed to load navigation",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [navigationUseCases]);
|
|
||||||
|
|
||||||
const createItem = useCallback(
|
|
||||||
async (data: CreateNavigationItemDTO) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
await navigationUseCases.createNavigationItem(data);
|
|
||||||
await loadNavigationTree();
|
|
||||||
setError(null);
|
|
||||||
toast({
|
|
||||||
title: "Success",
|
|
||||||
description: "Navigation item created successfully",
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to create item");
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description:
|
|
||||||
err instanceof Error ? err.message : "Failed to create item",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[navigationUseCases, loadNavigationTree]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateItem = useCallback(
|
|
||||||
async (id: string, data: UpdateNavigationItemDTO) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
await navigationUseCases.updateNavigationItem(id, data);
|
|
||||||
await loadNavigationTree();
|
|
||||||
setError(null);
|
|
||||||
toast({
|
|
||||||
title: "Success",
|
|
||||||
description: "Navigation item updated successfully",
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to update item");
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description:
|
|
||||||
err instanceof Error ? err.message : "Failed to update item",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[navigationUseCases, loadNavigationTree]
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteItem = useCallback(
|
|
||||||
async (id: string) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
await navigationUseCases.deleteNavigationItem(id);
|
|
||||||
await loadNavigationTree();
|
|
||||||
setError(null);
|
|
||||||
toast({
|
|
||||||
title: "Success",
|
|
||||||
description: "Navigation item deleted successfully",
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to delete item");
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description:
|
|
||||||
err instanceof Error ? err.message : "Failed to delete item",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[navigationUseCases, loadNavigationTree]
|
|
||||||
);
|
|
||||||
|
|
||||||
const moveItem = useCallback(
|
|
||||||
async (id: string, newParentPath: string, newOrderSeq: number) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
await navigationUseCases.moveItem(id, newParentPath, newOrderSeq);
|
|
||||||
await loadNavigationTree();
|
|
||||||
setError(null);
|
|
||||||
toast({
|
|
||||||
title: "Success",
|
|
||||||
description: "Navigation item moved successfully",
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to move item");
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description:
|
|
||||||
err instanceof Error ? err.message : "Failed to move item",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[navigationUseCases, loadNavigationTree]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadNavigationTree();
|
const fetchNavigation = async () => {
|
||||||
}, [loadNavigationTree]);
|
try {
|
||||||
|
const repository = new NavigationItemRepositoryImpl();
|
||||||
|
const navItems = await repository.findAll();
|
||||||
|
|
||||||
return {
|
// Convert flat structure to tree
|
||||||
items,
|
const buildNavigationTree = (
|
||||||
loading,
|
items: NavigationItem[],
|
||||||
error,
|
parentPath: string = ""
|
||||||
form,
|
): any[] => {
|
||||||
formData,
|
return items
|
||||||
errors,
|
.filter((item) => {
|
||||||
setFormData,
|
const itemPath = item.path.split("/").filter(Boolean);
|
||||||
createItem,
|
const parentPathParts = parentPath.split("/").filter(Boolean);
|
||||||
updateItem,
|
return (
|
||||||
deleteItem,
|
itemPath.length === parentPathParts.length + 1 &&
|
||||||
moveItem,
|
itemPath.slice(0, parentPathParts.length).join("/") ===
|
||||||
refresh: loadNavigationTree,
|
parentPathParts.join("/")
|
||||||
};
|
);
|
||||||
};
|
})
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
subItems: buildNavigationTree(items, item.path),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.orderSeq - b.orderSeq);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigationTree = buildNavigationTree(navItems);
|
||||||
|
setItems(navigationTree);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to fetch navigation"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchNavigation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { items, loading, error };
|
||||||
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import {
|
||||||
} from "@/src/applications/entities/models/navigation-item.model";
|
} from "@/src/applications/entities/models/navigation-item.model";
|
||||||
import { NavigationRepository } from "@/src/applications/repositories/navigation-item.repository";
|
import { NavigationRepository } from "@/src/applications/repositories/navigation-item.repository";
|
||||||
|
|
||||||
export class NavigationItemRepository implements NavigationRepository {
|
export class NavigationItemRepositoryImpl implements NavigationRepository {
|
||||||
async findAll(): Promise<NavigationItem[]> {
|
async findAll(): Promise<NavigationItem[]> {
|
||||||
return db.navigationItem.findMany({
|
return db.navigationItem.findMany({
|
||||||
orderBy: [{ path: "asc" }, { orderSeq: "asc" }],
|
orderBy: [{ path: "asc" }, { orderSeq: "asc" }],
|
||||||
|
|
Loading…
Reference in New Issue