diff --git a/sigap-website/app/protected/(admin-pages)/dashboard/page.tsx b/sigap-website/app/protected/(admin-pages)/dashboard/page.tsx index 590c3c3..c0fef36 100644 --- a/sigap-website/app/protected/(admin-pages)/dashboard/page.tsx +++ b/sigap-website/app/protected/(admin-pages)/dashboard/page.tsx @@ -1,4 +1,3 @@ -import { AppSidebar } from "@/components/app-sidebar"; import { Breadcrumb, BreadcrumbItem, diff --git a/sigap-website/app/protected/(admin-pages)/layout.tsx b/sigap-website/app/protected/(admin-pages)/layout.tsx index 1cf2c61..682f6a6 100644 --- a/sigap-website/app/protected/(admin-pages)/layout.tsx +++ b/sigap-website/app/protected/(admin-pages)/layout.tsx @@ -1,4 +1,3 @@ -import { checkSession } from "@/actions/auth/session"; import { AppSidebar } from "@/components/app-sidebar"; import { Breadcrumb, diff --git a/sigap-website/app/protected/(admin-pages)/map/page.tsx b/sigap-website/app/protected/(admin-pages)/map/page.tsx index 0625219..9554192 100644 --- a/sigap-website/app/protected/(admin-pages)/map/page.tsx +++ b/sigap-website/app/protected/(admin-pages)/map/page.tsx @@ -1,4 +1,3 @@ -import { AppSidebar } from "@/components/app-sidebar"; import { MapboxMap } from "@/components/map/mapbox-view"; import { Breadcrumb, diff --git a/sigap-website/components/app-sidebar.tsx b/sigap-website/components/app-sidebar.tsx index 046cb55..05654a8 100644 --- a/sigap-website/components/app-sidebar.tsx +++ b/sigap-website/components/app-sidebar.tsx @@ -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) { - const [navItems, setNavItems] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(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[] }) => ( - ({ - ...item, - icon: () => , - items: item.items.map((subItem: NavSubItems) => ({ - ...subItem, - icon: () => ( - - ), - })), - }))} - /> - ); - return ( - + - {isLoading ? ( -
- -
- ) : error ? ( -
{error}
- ) : ( - - )} - {/* */} + +
- +
diff --git a/sigap-website/components/nav-main.tsx b/sigap-website/components/nav-main.tsx index f75b873..ccdacf7 100644 --- a/sigap-website/components/nav-main.tsx +++ b/sigap-website/components/nav-main.tsx @@ -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 ( + + + + {item.title} + + + + ); +} + +function SubItemComponent({ item }: { item: SubItem }) { + const hasSubSubItems = item.subSubItems && item.subSubItems.length > 0; + + if (!hasSubSubItems) { + return ( + + + + {item.icon && } + {item.title} + + + + ); + } + + return ( + + + + + {item.icon && } + {item.title} + + + + + + {item.subSubItems!.map((subSubItem) => ( + + ))} + + + + + ); +} + +function RecursiveNavItem({ item, index }: { item: NavItem; index: number }) { + const hasSubItems = item.subItems && item.subItems.length > 0; + + if (!hasSubItems) { + return ( + + + + {item.icon && } + {item.title} + + + + ); + } + + return ( + + + + + {item.icon && } + {item.title} + {hasSubItems && ( + + )} + + + + + {item.subItems!.map((subItem) => ( + + ))} + + + + + ); +} + +export function NavMain({ items }: { items: NavItem[] }) { return ( Platform - {items.map((item) => ( - - - {item.items && item.items.length > 0 ? ( - - - {item.icon && } - {item.title} - - - - ) : ( - - - {item.icon && } - {item.title} - - - )} - - - {item.items?.map((subItem) => ( - - - - {subItem.icon && } - {subItem.title} - - - - ))} - - - - + {items.map((item, index) => ( + ))} diff --git a/sigap-website/components/nav-projects.tsx b/sigap-website/components/nav-projects.tsx index f50b20d..2634c52 100644 --- a/sigap-website/components/nav-projects.tsx +++ b/sigap-website/components/nav-projects.tsx @@ -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 ( @@ -85,5 +87,5 @@ export function NavProjects({ - ) + ); } diff --git a/sigap-website/src/infrastructure/hooks/use-navigation-item.ts b/sigap-website/src/infrastructure/hooks/use-navigation-item.ts index e87f3c7..5016c29 100644 --- a/sigap-website/src/infrastructure/hooks/use-navigation-item.ts +++ b/sigap-website/src/infrastructure/hooks/use-navigation-item.ts @@ -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([]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [formData, setFormData] = useState({ - title: "", - url: "", - slug: "", - icon: "", - isActive: false, - orderSeq: 0, - parentId: null, - }); - const [errors, setErrors] = useState< - Partial> - >({}); - - const form = useForm({ - 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 }; +} diff --git a/sigap-website/src/infrastructure/repositories/navigation-item.repository.impl.ts b/sigap-website/src/infrastructure/repositories/navigation-item.repository.impl.ts index 17cf52b..bce3ba2 100644 --- a/sigap-website/src/infrastructure/repositories/navigation-item.repository.impl.ts +++ b/sigap-website/src/infrastructure/repositories/navigation-item.repository.impl.ts @@ -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 { return db.navigationItem.findMany({ orderBy: [{ path: "asc" }, { orderSeq: "asc" }],