diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 37d7123..6fd0053 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -16,9 +16,14 @@ "@mantine/form": "^7.10.2", "@mantine/hooks": "^7.10.2", "@mantine/notifications": "^7.10.2", + "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-radio-group": "^1.2.0", + "@radix-ui/react-scroll-area": "^1.1.0", + "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-slot": "^1.1.0", "@tanstack/react-query": "^5.45.0", "@tanstack/react-router": "^1.38.1", diff --git a/apps/frontend/src/assets/logos/amati-logo.png b/apps/frontend/src/assets/logos/amati-logo.png new file mode 100644 index 0000000..bf15f56 Binary files /dev/null and b/apps/frontend/src/assets/logos/amati-logo.png differ diff --git a/apps/frontend/src/components/AppHeader.tsx b/apps/frontend/src/components/AppHeader.tsx index 836b93d..cab58ab 100644 --- a/apps/frontend/src/components/AppHeader.tsx +++ b/apps/frontend/src/components/AppHeader.tsx @@ -1,20 +1,13 @@ import { useState } from "react"; -import { - AppShell, - Avatar, - Burger, - Group, - Menu, - UnstyledButton, - Text, - rem, -} from "@mantine/core"; -import logo from "@/assets/logos/logo.png"; +import logo from "@/assets/logos/amati-logo.png"; import cx from "clsx"; import classNames from "./styles/appHeader.module.css"; -import { TbChevronDown } from "react-icons/tb"; +import { IoMdMenu } from "react-icons/io"; import { Link } from "@tanstack/react-router"; import useAuth from "@/hooks/useAuth"; +import { Avatar, AvatarFallback, AvatarImage } from "@/shadcn/components/ui/avatar"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/shadcn/components/ui/dropdown-menu"; +import { Button } from "@/shadcn/components/ui/button"; // import getUserMenus from "../actions/getUserMenus"; // import { useAuth } from "@/modules/auth/contexts/AuthContext"; // import UserMenuItem from "./UserMenuItem"; @@ -24,73 +17,73 @@ interface Props { toggle: () => void; } +interface User { + id: string; + name: string; + permissions: string[]; + photoProfile?: string; +} + // const mockUserData = { // name: "Fulan bin Fulanah", // email: "janspoon@fighter.dev", // image: "https://raw.githubusercontent.com/mantinedev/mantine/master/.demo/avatars/avatar-5.png", // }; -export default function AppHeader(props: Props) { +export default function AppHeader({ toggle }: Props) { const [userMenuOpened, setUserMenuOpened] = useState(false); - const { user } = useAuth(); + const { user }: { user: User | null } = useAuth(); // const userMenus = getUserMenus().map((item, i) => ( // // )); return ( - - - - - setUserMenuOpened(true)} - onClose={() => setUserMenuOpened(false)} - withinPortal +
+
+ + + + + + + + - - - Logout - - - {/* {userMenus} */} - -
-
-
+ + + Logout + + + + + ); } diff --git a/apps/frontend/src/components/AppNavbar.tsx b/apps/frontend/src/components/AppNavbar.tsx index d44397c..5e9af2d 100644 --- a/apps/frontend/src/components/AppNavbar.tsx +++ b/apps/frontend/src/components/AppNavbar.tsx @@ -1,7 +1,10 @@ -import { AppShell, ScrollArea } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import client from "../honoClient"; import MenuItem from "./NavbarMenuItem"; +import { useState, useEffect } from "react"; +import { useLocation } from "@tanstack/react-router"; +import { ScrollArea } from "@/shadcn/components/ui/scroll-area"; +import AppHeader from "./AppHeader"; // import MenuItem from "./SidebarMenuItem"; // import { useAuth } from "@/modules/auth/contexts/AuthContext"; @@ -15,30 +18,70 @@ import MenuItem from "./NavbarMenuItem"; export default function AppNavbar() { // const {user} = useAuth(); + const { pathname } = useLocation(); + + const [isSidebarOpen, setSidebarOpen] = useState(true); + const toggleSidebar = () => { + setSidebarOpen(!isSidebarOpen); + }; + const { data } = useQuery({ queryKey: ["sidebarData"], queryFn: async () => { const res = await client.dashboard.getSidebarItems.$get(); if (res.ok) { const data = await res.json(); - return data; } console.error("Error:", res.status, res.statusText); - - //TODO: Handle error properly throw new Error("Error fetching sidebar data"); }, }); + useEffect(() => { + const handleResize = () => { + if (window.innerWidth < 768) { // Ganti 768 dengan breakpoint mobile Anda + setSidebarOpen(false); + } else { + setSidebarOpen(true); + } + }; + + window.addEventListener('resize', handleResize); + handleResize(); // Initial check + + return () => window.removeEventListener('resize', handleResize); + }, []); + + const handleMenuItemClick = () => { + if (window.innerWidth < 768) { + setSidebarOpen(false); + } + }; + return ( - - - {data?.map((menu, i) => )} - {/* {user?.sidebarMenus.map((menu, i) => ( - - )) ?? null} */} - - + <> +
+ {/* Header */} + + + {/* Sidebar */} +
+ + {data?.map((menu, i) => ( + + ))} + +
+
+ ); } diff --git a/apps/frontend/src/components/DashboardTable.tsx b/apps/frontend/src/components/DashboardTable.tsx index 1b03a27..988435c 100644 --- a/apps/frontend/src/components/DashboardTable.tsx +++ b/apps/frontend/src/components/DashboardTable.tsx @@ -1,5 +1,13 @@ -import { Table, Center, ScrollArea } from "@mantine/core"; -import { Table as ReactTable, flexRender } from "@tanstack/react-table"; +import { ScrollArea } from "@/shadcn/components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/shadcn/components/ui/table"; +import { flexRender, Table as ReactTable } from "@tanstack/react-table"; interface Props { table: ReactTable; @@ -7,68 +15,55 @@ interface Props { export default function DashboardTable({ table }: Props) { return ( - - - {/* Thead */} - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ))} - +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + ))} - + + ))} + - {/* Tbody */} - - {table.getRowModel().rows.length > 0 ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} - - )) - ) : ( - - -
- No Data -
-
-
- )} -
+ + {table.getRowModel().rows.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + - No Data - + + + )} +
-
+ ); } diff --git a/apps/frontend/src/components/NavbarChildMenu.tsx b/apps/frontend/src/components/NavbarChildMenu.tsx index 3f09f2f..5ed22ef 100644 --- a/apps/frontend/src/components/NavbarChildMenu.tsx +++ b/apps/frontend/src/components/NavbarChildMenu.tsx @@ -1,5 +1,3 @@ -import { Text } from "@mantine/core"; - import classNames from "./styles/navbarChildMenu.module.css"; import { SidebarMenu } from "backend/types"; @@ -22,13 +20,10 @@ export default function ChildMenu(props: Props) { : `/${props.item.link}`; return ( - - component="a" - className={classNames.link} - href={`${linkPath}`} - fw={props.active ? "bold" : "normal"} + {props.item.label} - + ); } diff --git a/apps/frontend/src/components/NavbarMenuItem.tsx b/apps/frontend/src/components/NavbarMenuItem.tsx index fc0e202..4b96a4a 100644 --- a/apps/frontend/src/components/NavbarMenuItem.tsx +++ b/apps/frontend/src/components/NavbarMenuItem.tsx @@ -1,17 +1,6 @@ import { useState } from "react"; - -import { - Box, - Collapse, - Group, - ThemeIcon, - UnstyledButton, - rem, -} from "@mantine/core"; -import { TbChevronRight } from "react-icons/tb"; import * as TbIcons from "react-icons/tb"; - -import classNames from "./styles/navbarMenuItem.module.css"; +// import classNames from "./styles/navbarMenuItem.module.css"; // import dashboardConfig from "../dashboard.config"; // import { usePathname } from "next/navigation"; // import areURLsSame from "@/utils/areUrlSame"; @@ -19,9 +8,14 @@ import classNames from "./styles/navbarMenuItem.module.css"; import { SidebarMenu } from "backend/types"; import ChildMenu from "./NavbarChildMenu"; import { Link } from "@tanstack/react-router"; +import { Button } from "@/shadcn/components/ui/button"; +import { ChevronRightIcon} from "lucide-react"; +import { cn } from "@/lib/utils"; interface Props { menu: SidebarMenu; + isActive: boolean; + onClick: (link: string) => void; } //TODO: Make bold and collapsed when the item is active @@ -34,7 +28,7 @@ interface Props { * @param props.menu - The menu item data to display. * @returns A React element representing an individual menu item. */ -export default function MenuItem({ menu }: Props) { +export default function MenuItem({ menu, isActive, onClick }: Props) { const hasChildren = Array.isArray(menu.children); // const pathname = usePathname(); @@ -50,6 +44,13 @@ export default function MenuItem({ menu }: Props) { setOpened((prev) => !prev); }; + const handleClick = () => { + onClick(menu.link ?? ""); + if (!hasChildren) { + toggleOpenMenu(); + } + }; + // Mapping children menu items if available const subItems = (hasChildren ? menu.children! : []).map((child, index) => ( @@ -69,43 +70,41 @@ export default function MenuItem({ menu }: Props) { return ( <> {/* Main Menu Item */} - - onClick={toggleOpenMenu} - className={`${classNames.control} py-2`} - to={menu.link} - component={menu.link ? Link : "button"} + {/* Collapsible Sub-Menu */} - {hasChildren && {subItems}} + {hasChildren && ( +
+ {subItems} +
+ )} ); } diff --git a/apps/frontend/src/components/PageTemplate.tsx b/apps/frontend/src/components/PageTemplate.tsx index 3107229..b0cf287 100644 --- a/apps/frontend/src/components/PageTemplate.tsx +++ b/apps/frontend/src/components/PageTemplate.tsx @@ -1,16 +1,4 @@ /* eslint-disable no-mixed-spaces-and-tabs */ -import { - Button, - Card, - Flex, - Pagination, - Select, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { Link } from "@tanstack/react-router"; import React, { ReactNode, useState } from "react"; import { TbPlus, TbSearch } from "react-icons/tb"; import DashboardTable from "./DashboardTable"; @@ -25,7 +13,25 @@ import { keepPreviousData, useQuery, } from "@tanstack/react-query"; -import { useDebouncedCallback } from "@mantine/hooks"; +import { useDebouncedValue } from "@mantine/hooks"; +import { Button } from "@/shadcn/components/ui/button"; +import { useNavigate } from "@tanstack/react-router"; +import { Card } from "@/shadcn/components/ui/card"; +import { Input } from "@/shadcn/components/ui/input"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, +} from "@/shadcn/components/ui/pagination"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shadcn/components/ui/select"; +import { HiChevronLeft, HiChevronRight } from "react-icons/hi"; type PaginatedResponse> = { data: Array; @@ -70,24 +76,32 @@ const createCreateButton = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any property: Props["createButton"] = true ) => { + const navigate = useNavigate(); + + const addQuery = () => { + navigate({ to: `${window.location.pathname}`, search: { create: true } }); + } + if (property === true) { return ( ); } else if (typeof property === "string") { return ( ); } else { @@ -95,6 +109,109 @@ const createCreateButton = ( } }; +/** + * Pagination component for handling page navigation. + * + * @param props - The properties object. + * @returns The rendered Pagination component. + */ +const CustomPagination = ({ + currentPage, + totalPages, + onChange, +}: { + currentPage: number; + totalPages: number; + onChange: (page: number) => void; +}) => { + const getPaginationItems = () => { + let items = []; + + // Determine start and end pages + let startPage = + currentPage == totalPages && currentPage > 3 ? + Math.max(1, currentPage - 2) : + Math.max(1, currentPage - 1); + let endPage = + currentPage == 1 ? + Math.min(totalPages, currentPage + 2) : + Math.min(totalPages, currentPage + 1); + + // Add ellipsis if needed + if (startPage > 2) { + items.push(); + } + + // Add page numbers + for (let i = startPage; i <= endPage; i++) { + items.push( + + ); + } + + // Add ellipsis after + if (endPage < totalPages - 1) { + items.push(); + } + + // Add last page + if (endPage < totalPages) { + items.push( + + ); + } + if (currentPage > 2) { + items.unshift( + + ); + } + + return items; + }; + + return ( + + + + + +
+ {getPaginationItems().map((item) => ( + + {item} + + ))} +
+ + + +
+
+ ); +}; + /** * PageTemplate component for displaying a paginated table with search and filter functionality. @@ -113,15 +230,15 @@ export default function PageTemplate< q: "", }); - // const [deboucedSearchQuery] = useDebouncedValue(filterOptions.q, 500); + const [debouncedSearchQuery] = useDebouncedValue(filterOptions.q, 500); const query = useQuery({ ...(typeof props.queryOptions === "function" ? props.queryOptions( - filterOptions.page, - filterOptions.limit, - filterOptions.q - ) + filterOptions.page, + filterOptions.limit, + debouncedSearchQuery + ) : props.queryOptions), placeholderData: keepPreviousData, }); @@ -131,7 +248,11 @@ export default function PageTemplate< columns: props.columnDefs, getCoreRowModel: getCoreRowModel(), defaultColumn: { - cell: (props) => {props.getValue() as ReactNode}, + cell: (props) => ( + + {props.getValue() as ReactNode} + + ), }, }); @@ -140,13 +261,13 @@ export default function PageTemplate< * * @param value - The new search query value. */ - const handleSearchQueryChange = useDebouncedCallback((value: string) => { + const handleSearchQueryChange = (value: string) => { setFilterOptions((prev) => ({ page: 0, limit: prev.limit, q: value, })); - }, 500); + }; /** * Handles the change in page number. @@ -155,33 +276,34 @@ export default function PageTemplate< */ const handlePageChange = (page: number) => { setFilterOptions((prev) => ({ - page: page - 1, + page: page - 1, // Adjust for zero-based index limit: prev.limit, q: prev.q, })); }; return ( - - {props.title} - - {/* Top Section */} - - {createCreateButton(props.createButton)} - - +
+

{props.title}

+ {/* Table Functionality */}
- {/* Search */} -
- } - value={filterOptions.q} - onChange={(e) => - handleSearchQueryChange(e.target.value) - } - placeholder="Search..." - /> + {/* Search and Create Button */} +
+
+ + handleSearchQueryChange(e.target.value)} + placeholder="Pencarian..." + /> +
+
+ {createCreateButton(props.createButton)} +
{/* Table */} @@ -189,41 +311,50 @@ export default function PageTemplate< {/* Pagination */} {query.data && ( -
- + setFilterOptions((prev) => ({ + page: prev.page, + limit: parseInt(value ?? "10"), + q: prev.q, + })) + } + defaultValue="10" + > + + + + + 5 + 10 + 50 + 100 + 500 + 1000 + + +
+ - - Showing {query.data.data.length} of{" "} - {query.data._metadata.totalItems} - +
+ + Menampilkan {query.data.data.length} dari {query.data._metadata.totalItems} + +
)}
- {/* The Modals */} {props.modals?.map((modal, index) => ( {modal} ))}
- +
); } diff --git a/apps/frontend/src/modules/usersManagement/tables/columns.tsx b/apps/frontend/src/modules/usersManagement/tables/columns.tsx index d29d65c..134f191 100644 --- a/apps/frontend/src/modules/usersManagement/tables/columns.tsx +++ b/apps/frontend/src/modules/usersManagement/tables/columns.tsx @@ -1,5 +1,4 @@ import { createColumnHelper } from "@tanstack/react-table"; -import { Badge, Flex, Group, Avatar, Text, Anchor } from "@mantine/core"; import { TbEye, TbPencil, TbTrash } from "react-icons/tb"; import { CrudPermission } from "@/types"; import stringToColorHex from "@/utils/stringToColorHex"; @@ -7,6 +6,8 @@ import createActionButtons from "@/utils/createActionButton"; import client from "@/honoClient"; import { InferResponseType } from "hono"; import { Link } from "@tanstack/react-router"; +import { Badge } from "@/shadcn/components/ui/badge"; +import { Avatar } from "@/shadcn/components/ui/avatar"; interface ColumnOptions { permissions: Partial; @@ -29,31 +30,28 @@ const createColumns = (options: ColumnOptions) => { columnHelper.accessor("name", { header: "Name", cell: (props) => ( - +
{props.getValue()?.[0].toUpperCase()} - + {props.getValue()} - - + +
), }), columnHelper.accessor("email", { header: "Email", cell: (props) => ( - + {props.getValue()} - + ), }), @@ -66,7 +64,7 @@ const createColumns = (options: ColumnOptions) => { id: "status", header: "Status", cell: (props) => ( - + {props.row.original.isEnabled ? "Active" : "Inactive"} ), @@ -80,7 +78,7 @@ const createColumns = (options: ColumnOptions) => { className: "w-fit", }, cell: (props) => ( - +
{createActionButtons([ { label: "Detail", @@ -104,7 +102,7 @@ const createColumns = (options: ColumnOptions) => { icon: , }, ])} - +
), }), ]; diff --git a/apps/frontend/src/routes/_dashboardLayout.tsx b/apps/frontend/src/routes/_dashboardLayout.tsx index e5d8517..5de5782 100644 --- a/apps/frontend/src/routes/_dashboardLayout.tsx +++ b/apps/frontend/src/routes/_dashboardLayout.tsx @@ -1,68 +1,65 @@ -import { AppShell } from "@mantine/core"; import { Navigate, Outlet, createFileRoute } from "@tanstack/react-router"; -import { useDisclosure } from "@mantine/hooks"; import AppHeader from "../components/AppHeader"; import AppNavbar from "../components/AppNavbar"; import useAuth from "@/hooks/useAuth"; import { useQuery } from "@tanstack/react-query"; import fetchRPC from "@/utils/fetchRPC"; import client from "@/honoClient"; +import { useState } from "react"; export const Route = createFileRoute("/_dashboardLayout")({ - component: DashboardLayout, + component: DashboardLayout, - // beforeLoad: ({ location }) => { - // if (true) { - // throw redirect({ - // to: "/login", - // }); - // } - // }, + // beforeLoad: ({ location }) => { + // if (true) { + // throw redirect({ + // to: "/login", + // }); + // } + // }, }); function DashboardLayout() { - const { isAuthenticated, saveAuthData } = useAuth(); + const { isAuthenticated, saveAuthData } = useAuth(); - useQuery({ - queryKey: ["my-profile"], - queryFn: async () => { - const response = await fetchRPC(client.auth["my-profile"].$get()); + useQuery({ + queryKey: ["my-profile"], + queryFn: async () => { + const response = await fetchRPC(client.auth["my-profile"].$get()); - saveAuthData({ - id: response.id, - name: response.name, - permissions: response.permissions, - }); + saveAuthData({ + id: response.id, + name: response.name, + permissions: response.permissions, + }); - return response; - }, - enabled: isAuthenticated, - }); + return response; + }, + enabled: isAuthenticated, + }); - const [openNavbar, { toggle }] = useDisclosure(false); + const [openNavbar, setNavbarOpen] = useState(true); + const toggle = () => { + setNavbarOpen(!openNavbar); + }; - return isAuthenticated ? ( - - + return isAuthenticated ? ( +
+ {/* Header */} + - + {/* Main Content Area */} +
+ {/* Sidebar */} + - - - - - ) : ( - - ); + {/* Main Content */} +
+ +
+
+
+ ) : ( + + ); } diff --git a/apps/frontend/src/shadcn/components/ui/avatar.tsx b/apps/frontend/src/shadcn/components/ui/avatar.tsx new file mode 100644 index 0000000..51e507b --- /dev/null +++ b/apps/frontend/src/shadcn/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/apps/frontend/src/shadcn/components/ui/badge.tsx b/apps/frontend/src/shadcn/components/ui/badge.tsx new file mode 100644 index 0000000..f000e3e --- /dev/null +++ b/apps/frontend/src/shadcn/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/apps/frontend/src/shadcn/components/ui/breadcrumb.tsx b/apps/frontend/src/shadcn/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..71a5c32 --- /dev/null +++ b/apps/frontend/src/shadcn/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>