Rekursif nav items but not finished yet

This commit is contained in:
vergiLgood1 2025-02-21 08:21:55 +07:00
parent d14cd33ef3
commit bf12ded925
8 changed files with 410 additions and 384 deletions

View File

@ -1,4 +1,3 @@
import { AppSidebar } from "@/components/app-sidebar";
import {
Breadcrumb,
BreadcrumbItem,

View File

@ -1,4 +1,3 @@
import { checkSession } from "@/actions/auth/session";
import { AppSidebar } from "@/components/app-sidebar";
import {
Breadcrumb,

View File

@ -1,4 +1,3 @@
import { AppSidebar } from "@/components/app-sidebar";
import { MapboxMap } from "@/components/map/mapbox-view";
import {
Breadcrumb,

View File

@ -1,20 +1,28 @@
"use client";
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 { NavProjects } from "@/components/nav-projects";
import { NavUser } from "@/components/nav-user";
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 {
Sidebar,
SidebarContent,
@ -22,18 +30,9 @@ import {
SidebarHeader,
SidebarRail,
} 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
const NAV_ITEMS_STORAGE_KEY = "navItemsData";
// 🎨 Data fallback
const fallbackData = {
// This is sample data.
const data = {
user: {
name: "shadcn",
email: "m@example.com",
@ -42,160 +41,258 @@ const fallbackData = {
teams: [
{
name: "Acme Inc",
logo: GalleryVerticalEnd,
logo: IconAlbum,
plan: "Enterprise",
},
{
name: "Acme Corp.",
logo: AudioWaveform,
logo: IconMusicBolt,
plan: "Startup",
},
{
name: "Evil Corp.",
logo: Command,
logo: IconCommand,
plan: "Free",
},
],
navMain: [
{
title: "Playground",
url: "#",
icon: SquareTerminal,
title: "Dashboard",
url: "/dashboard",
slug: "dashboard",
orderSeq: 1,
icon: IconHome,
isActive: true,
items: [
subItems: [],
},
{
title: "Crime Management",
url: "/crime-management",
slug: "crime-management",
orderSeq: 2,
icon: IconAlertTriangle,
isActive: true,
subItems: [
{
title: "History",
url: "#",
title: "Crime Overview",
url: "/crime-management/crime-overview",
slug: "crime-overview",
icon: IconAlertTriangle,
orderSeq: 1,
isActive: true,
},
{
title: "Starred",
url: "#",
title: "Crime Categories",
url: "/crime-management/crime-categories",
slug: "crime-categories",
icon: IconSettings,
orderSeq: 2,
isActive: true,
},
{
title: "Settings",
url: "#",
title: "Cases",
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: [
{
name: "Design Engineering",
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>) {
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 (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<TeamSwitcher teams={fallbackData.teams} />
<TeamSwitcher teams={data.teams} />
</SidebarHeader>
<SidebarContent>
{isLoading ? (
<div className="flex justify-center items-center py-8">
<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} /> */}
<NavMain items={data.navMain} />
<NavProjects projects={data.projects} />
</SidebarContent>
<SidebarFooter>
<NavUser user={fallbackData.user} />
<NavUser user={data.user} />
</SidebarFooter>
<SidebarRail />
</Sidebar>

View File

@ -1,12 +1,12 @@
"use client"
"use client";
import { ChevronRight, type LucideIcon } from "lucide-react"
import { ChevronRight } from "lucide-react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
} from "@/components/ui/collapsible";
import {
SidebarGroup,
SidebarGroupLabel,
@ -14,71 +14,132 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/components/ui/sidebar"
} from "@/components/ui/sidebar";
import * as TablerIcons from "@tabler/icons-react";
export function NavMain({
items,
}: {
items: {
title: string;
url: string;
icon?: TablerIcons.Icon;
isActive?: boolean;
items?: {
title: string;
icon?: TablerIcons.Icon;
url: string;
}[];
}[];
}) {
interface SubSubItem {
title: string;
url: string;
}
interface SubItem {
title: string;
url: string;
icon?: TablerIcons.Icon;
subSubItems?: SubSubItem[];
}
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 (
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<Collapsible
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>
{items.map((item, index) => (
<RecursiveNavItem key={item.title} item={item} index={index} />
))}
</SidebarMenu>
</SidebarGroup>

View File

@ -25,16 +25,18 @@ import {
useSidebar,
} from "@/components/ui/sidebar"
import * as TablerIcons from "@tabler/icons-react";
export function NavProjects({
projects,
}: {
projects: {
name: string
url: string
icon: LucideIcon
}[]
name: string;
url: string;
icon: TablerIcons.Icon;
}[];
}) {
const { isMobile } = useSidebar()
const { isMobile } = useSidebar();
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
@ -85,5 +87,5 @@ export function NavProjects({
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
);
}

View File

@ -1,184 +1,53 @@
import { useState, useCallback, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
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";
import { NavigationItem } from "@/src/applications/entities/models/navigation-item.model";
import { useEffect, useState } from "react";
import { NavigationItemRepositoryImpl } from "../repositories/navigation-item.repository.impl";
export const useNavigationItem = (navigationUseCases: NavigationUseCases) => {
export function useNavigationItem() {
const [items, setItems] = useState<NavigationItem[]>([]);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
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(() => {
loadNavigationTree();
}, [loadNavigationTree]);
const fetchNavigation = async () => {
try {
const repository = new NavigationItemRepositoryImpl();
const navItems = await repository.findAll();
return {
items,
loading,
error,
form,
formData,
errors,
setFormData,
createItem,
updateItem,
deleteItem,
moveItem,
refresh: loadNavigationTree,
};
};
// Convert flat structure to tree
const buildNavigationTree = (
items: NavigationItem[],
parentPath: string = ""
): any[] => {
return items
.filter((item) => {
const itemPath = item.path.split("/").filter(Boolean);
const parentPathParts = parentPath.split("/").filter(Boolean);
return (
itemPath.length === parentPathParts.length + 1 &&
itemPath.slice(0, parentPathParts.length).join("/") ===
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 };
}

View File

@ -8,7 +8,7 @@ import {
} from "@/src/applications/entities/models/navigation-item.model";
import { NavigationRepository } from "@/src/applications/repositories/navigation-item.repository";
export class NavigationItemRepository implements NavigationRepository {
export class NavigationItemRepositoryImpl implements NavigationRepository {
async findAll(): Promise<NavigationItem[]> {
return db.navigationItem.findMany({
orderBy: [{ path: "asc" }, { orderSeq: "asc" }],