feat(backoffice): Backoffice for Hackathon - user management page (#67)

* feat(backoffice): create a boilerplate page for Hackathon dashboard

- Create an empty page for Hackathon dashboard
- Comment out and hide the existing backoffice sidebar

* feat(backoffice): Create a nested/dropdown sidebar list

- Create a dropdown sidebar list
- Show back the old navigation and group them
- Make the sidebar responsive for mobile view

* test datatable with mock data

* base UI for Hackathon backoffice

TODO:
- Organize table schema for users, teams, and submissions management
- Create API Contract for additional back-end endpoint

* feat(hackathon): draft data table column & API Contract

* update endpoint

* feat(backoffice): update page hackathon user management

- update data table component
- update filtering & pagination
- add modal display to edit and add user
- hide notification icon in backoffice wrapper

* feat(backoffice): add API contract for hackathon users

* feat(backoffice): little adjustment in hackathon users management UI and API contract
This commit is contained in:
Hafid Nur
2025-12-05 11:55:28 +07:00
committed by GitHub
parent d923237969
commit a4c75770ae
13 changed files with 2400 additions and 127 deletions
@@ -3,64 +3,257 @@ import {
AuditOutlined,
BookOutlined,
CommentOutlined,
DownOutlined,
InboxOutlined,
LogoutOutlined,
ReadOutlined,
ReloadOutlined,
RightOutlined,
ScheduleOutlined,
SettingOutlined,
StockOutlined,
UsergroupAddOutlined,
UserOutlined,
UserSwitchOutlined,
} from '@ant-design/icons';
import { Button } from '../../atoms';
import { FC, ReactElement } from 'react';
import { FC, ReactElement, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn, For, useSession } from '@imphnen-frontend-service/utils';
const MENUS = [
{ label: 'Dashboard & Set Gacha', href: '/dashboard', icon: <AppstoreOutlined className="text-[20px]" /> },
{ label: 'Dashboard - Dimentorin', href: '/dashboard-dimentorin', icon: <AppstoreOutlined className="text-[20px]" /> },
{ label: 'Gacha Roll', href: '/gacha-roll', icon: <ReloadOutlined className="text-[20px]" /> },
{ label: 'Permissions', href: '/permissions', icon: <UserSwitchOutlined className="text-[20px]" /> },
{ label: 'Roles', href: '/roles', icon: <UsergroupAddOutlined className="text-[20px]" /> },
{ label: 'Data Akun', href: '/accounts', icon: <UserOutlined className="text-[20px]" /> },
{ label: 'Validasi Transaksi', href: '/transactions', icon: <AuditOutlined className="text-[20px]" /> },
{ label: 'Data Pengiriman Hadiah', href: '/prizes', icon: <InboxOutlined className="text-[20px]" /> },
{ label: 'User - Dimentorin', href: '/users-dimentorin', icon: <UserSwitchOutlined className="text-[20px]" /> },
{ label: 'Session - Dimentorin', href: '/session-dimentorin', icon: <ScheduleOutlined className="text-[20px]" /> },
{ label: 'Content & Roadmap', href: '/roadmap-dimentorin', icon: <BookOutlined className="text-[20px]" /> },
{ label: 'Feedback & Review', href: '/feedback-review-dimentorin', icon: <CommentOutlined className="text-[20px]" /> },
{ label: 'Settings - Dimentorin', href: '/settings-dimentorin', icon: <SettingOutlined className="text-[20px]" /> },
]
type MenuItem = {
label: string;
href?: string;
icon?: ReactElement;
children?: Array<{ label: string; href: string; icon?: ReactElement }>;
};
export const BackofficeSidebar: FC = (): ReactElement => {
const MENUS: MenuItem[] = [
{
label: 'Hackathon',
icon: <StockOutlined className="text-p3" />,
children: [
{
label: 'Dashboard',
href: '/hackathon-dashboard',
icon: <AppstoreOutlined className="text-p3" />,
},
{
label: 'Users',
href: '/hackathon-users',
icon: <UserOutlined className="text-p3" />,
},
{
label: 'Teams',
href: '/hackathon-teams',
icon: <UsergroupAddOutlined className="text-p3" />,
},
{
label: 'Submissions',
href: '/hackathon-submissions',
icon: <AuditOutlined className="text-p3" />,
},
],
},
{
label: 'Dimentorin',
icon: <ReadOutlined className="text-p3" />,
children: [
{
label: 'Dashboard - Dimentorin',
href: '/dashboard-dimentorin',
icon: <AppstoreOutlined className="text-[20px]" />,
},
{
label: 'User - Dimentorin',
href: '/users-dimentorin',
icon: <UserSwitchOutlined className="text-[20px]" />,
},
{
label: 'Session - Dimentorin',
href: '/session-dimentorin',
icon: <ScheduleOutlined className="text-[20px]" />,
},
{
label: 'Content & Roadmap',
href: '/roadmap-dimentorin',
icon: <BookOutlined className="text-[20px]" />,
},
{
label: 'Feedback & Review',
href: '/feedback-review-dimentorin',
icon: <CommentOutlined className="text-[20px]" />,
},
{
label: 'Settings - Dimentorin',
href: '/settings-dimentorin',
icon: <SettingOutlined className="text-[20px]" />,
},
],
},
{
label: 'Gacha',
icon: <ReloadOutlined className="text-[20px]" />,
children: [
{
label: 'Dashboard & Set Gacha',
href: '/dashboard',
icon: <AppstoreOutlined className="text-[20px]" />,
},
{
label: 'Gacha Roll',
href: '/gacha-roll',
icon: <ReloadOutlined className="text-[20px]" />,
},
{
label: 'Validasi Transaksi',
href: '/transactions',
icon: <AuditOutlined className="text-[20px]" />,
},
{
label: 'Data Pengiriman Hadiah',
href: '/prizes',
icon: <InboxOutlined className="text-[20px]" />,
},
],
},
{
label: 'Permissions',
href: '/permissions',
icon: <UserSwitchOutlined className="text-[20px]" />,
},
{
label: 'Roles',
href: '/roles',
icon: <UsergroupAddOutlined className="text-[20px]" />,
},
{
label: 'Data Akun',
href: '/accounts',
icon: <UserOutlined className="text-[20px]" />,
},
];
interface SidebarProps {
isOpen?: boolean;
onClose?: () => void;
}
export const BackofficeSidebar: FC<SidebarProps> = ({
isOpen = false,
onClose,
}): ReactElement => {
const { signOut } = useSession();
const location = useLocation();
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
const isActive = (path: string) => {
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin') return false
return location.pathname.includes(path)
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin')
return false;
return location.pathname.includes(path);
};
return (
<aside className="sticky top-0 left-0 w-[280px] bg-white h-svh py-[60px] px-[28px] shadow-xl flex flex-col justify-between">
<div className="flex flex-col gap-20 justify-between items-center">
<img src="/logos/simple.svg" alt="IMPHNEN Logo" className="w-[150px]" />
const toggleGroup = (groupLabel: string) => {
setOpenGroups((prev) => ({ ...prev, [groupLabel]: !prev[groupLabel] }));
};
const sidebarContent = (
<div className="w-[280px] bg-white h-svh py-10 lg:py-[60px] px-7 shadow-xl flex flex-col justify-between">
<div className="flex flex-col gap-10 lg:gap-20 justify-between items-center">
<div className="flex justify-around lg:justify-center items-center w-full">
<img
src="/logos/simple.svg"
alt="IMPHNEN Logo"
className="w-[150px]"
/>
{onClose && (
<button
onClick={onClose}
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
aria-label="Close sidebar"
>
<svg
className="w-5 h-5 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
<nav className="flex flex-col gap-4 w-full h-[calc(100svh-20rem)] overflow-y-auto">
<For data={MENUS}>
{({ label, href, icon }) => (
<Link
key={href}
to={href}
className={cn(
"flex items-center justify-items-start gap-3 px-[8px] py-[10px]",
isActive(href) ? "bg-primary-500 text-white rounded-md" : "text-gray-700 hover:bg-gray-100"
)}
>
{icon}
<span className="text-p3 font-medium">{label}</span>
</Link>
)}
{(menu) =>
menu.children && menu.children.length > 0 ? (
<div key={menu.label} className="w-full">
<button
type="button"
onClick={() => toggleGroup(menu.label)}
className={cn(
'flex items-center justify-between w-full gap-3 px-2 py-2.5 rounded-md cursor-pointer',
openGroups[menu.label]
? 'bg-primary-400 hover:bg-primary-500 text-white'
: 'text-gray-700 hover:bg-gray-100'
)}
>
<div className="flex items-center gap-3">
{menu.icon}
<span className="text-p3 font-medium">{menu.label}</span>
</div>
<span className="text-label2">
{openGroups[menu.label] ? (
<DownOutlined className="text-label1" />
) : (
<RightOutlined className="text-label1" />
)}
</span>
</button>
{openGroups[menu.label] && (
<div className="mt-2 ml-6 flex flex-col gap-2">
{menu.children.map((child) => (
<Link
key={child.href}
to={child.href}
className={cn(
'flex items-center gap-3 px-2 py-2.5 rounded-md',
isActive(child.href)
? 'bg-primary-100 text-primary-700 hover:bg-primary-200'
: 'text-gray-700 hover:bg-gray-100'
)}
>
{child.icon}
<span className="text-label1 font-medium">
{child.label}
</span>
</Link>
))}
</div>
)}
</div>
) : (
<Link
key={menu.href ?? menu.label}
to={menu.href ?? '#'}
className={cn(
'flex items-center justify-items-start gap-3 px-2 py-2.5',
menu.href && isActive(menu.href)
? 'bg-primary-500 text-white rounded-md'
: 'text-gray-700 hover:bg-gray-100'
)}
>
{menu.icon}
<span className="text-p3 font-medium">{menu.label}</span>
</Link>
)
}
</For>
</nav>
</div>
@@ -70,12 +263,36 @@ export const BackofficeSidebar: FC = (): ReactElement => {
<Button
onClick={signOut}
variant="text"
className="items-start justify-start gap-3 px-[8px] py-[10px] text-gray-700 hover:text-red-500 transition-colors w-full"
className="items-start justify-start gap-3 px-2 py-2.5 text-gray-700 hover:text-red-500 transition-colors w-full"
>
<LogoutOutlined className="text-[20px]" />
<LogoutOutlined className="text-p3" />
<span className="text-p3 font-medium">Log Out</span>
</Button>
</div>
</aside>
</div>
);
return (
<>
{/* Desktop Sidebar - visible on lg+, sticky */}
<div className="hidden lg:block sticky top-0 h-screen overflow-y-auto shadow">
{sidebarContent}
</div>
{/* Mobile Sidebar - overlay */}
{isOpen && (
<div className="lg:hidden fixed inset-0 z-50">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 transition-opacity"
onClick={onClose}
/>
{/* Sidebar */}
<div className="fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out">
{sidebarContent}
</div>
</div>
)}
</>
);
};
@@ -1,15 +1,16 @@
import { cn } from "@imphnen-frontend-service/utils"
import { Icon } from '@iconify/react'
import { FC, ReactElement, ReactNode } from "react"
import { Button } from "../../atoms"
import { cn } from '@imphnen-frontend-service/utils';
import { Icon } from '@iconify/react';
import { FC, ReactElement, ReactNode } from 'react';
import { Button } from '../../atoms';
import { useAuthStore } from '@imphnen-frontend-service/service';
export type TBackofficeWrapperProps = {
children: ReactNode
title?: string
className?: string
classHeader?: string
classTitle?: string
}
children: ReactNode;
title?: string;
className?: string;
classHeader?: string;
classTitle?: string;
};
export const BackofficeWrapper: FC<TBackofficeWrapperProps> = ({
children,
@@ -18,34 +19,52 @@ export const BackofficeWrapper: FC<TBackofficeWrapperProps> = ({
classHeader,
classTitle,
}): ReactElement => {
const { session } = useAuthStore();
const user = session?.user;
return (
<main className={cn("w-full px-[48px] py-[40px] flex flex-col gap-8", className)}>
<header className={cn("bg-white py-5 px-7 rounded-md shadow flex items-center justify-between", classHeader)}>
<h1 className={cn("text-[19px] text-primary-500 font-semibold", classTitle)}>{title}</h1>
<main
className={cn(
'w-full px-[48px] py-[40px] flex flex-col gap-8',
className
)}
>
<header
className={cn(
'bg-white py-5 px-7 rounded-md shadow flex items-center justify-between',
classHeader
)}
>
<h1
className={cn(
'text-[19px] text-primary-500 font-semibold',
classTitle
)}
>
{title}
</h1>
<div className="flex items-center gap-x-6">
<Button
type="button"
variant="secondary"
className="max-h-full p-3"
>
{/* <Button type="button" variant="secondary" className="max-h-full p-3">
<Icon icon="mdi:bell-outline" className="size-6" />
</Button>
</Button> */}
<div className="flex items-center gap-x-6">
<div className="text-neutral-600 font-medium">
<p className="text-p3">Rizal Syaepulloh</p>
<p className="text-label1">Super Admin</p>
<p className="text-p3">{user?.fullname || 'Full Name'}</p>
<p className="text-label1">Admin</p>
</div>
<div className="size-12 rounded-full overflow-hidden">
<img src="/images/asd687hwq6nds4dfjj2983.webp" alt="Profile" className="size-full object-cover" />
<img
src={user?.avatar || '/images/asd687hwq6nds4dfjj2983.webp'}
alt="Profile"
className="size-full object-cover"
/>
</div>
</div>
</div>
</header>
<section>
{children}
</section>
<section>{children}</section>
</main>
)
}
);
};
+117 -31
View File
@@ -1,57 +1,113 @@
import {
PaginationState,
SortingState,
useReactTable,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
ColumnDef,
Table,
RowData,
TableOptions,
} from '@tanstack/react-table';
import { Pagination } from '../../molecules';
import React from 'react';
import { cn } from '@imphnen-frontend-service/utils';
interface DataTableProps<T> {
data: T[];
columns: ColumnDef<T>[];
table: Table<T>;
interface DataTableProps<T extends RowData> {
table?: Table<T>;
data?: T[];
columns?: ColumnDef<T, unknown>[];
pageSize?: number;
className?: string;
}
export const DataTable = <T extends RowData>({
data,
columns,
table,
data = [],
columns = [],
pageSize = 9,
className,
}: DataTableProps<T>) => {
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize,
});
const [sorting, setSorting] = React.useState<SortingState>([]);
const table = useReactTable({
data,
columns,
state: {
pagination,
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
});
// Update pagination state when pageSize prop changes
React.useEffect(() => {
setPagination((prev) => ({
...prev,
pageSize,
}));
}, [pageSize]);
// Reset pagination when data changes to prevent out-of-bounds errors
React.useEffect(() => {
if (data.length > 0) {
setPagination((prev) => ({
...prev,
pageIndex: 0, // Reset to first page when data changes
}));
}
}, [data.length]);
// Memoize data and columns to prevent unnecessary re-renders
const memoizedData = React.useMemo(() => data, [data]);
const memoizedColumns = React.useMemo(() => columns, [columns]);
// Memoize table configuration to prevent recreation on every render
const tableConfig = React.useMemo(() => {
const config: TableOptions<T> = {
data: memoizedData,
columns: memoizedColumns,
state: {
pagination,
sorting,
},
onPaginationChange: setPagination,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
};
return config;
}, [memoizedData, memoizedColumns, pagination, sorting]);
// Prefer external table instance if provided; otherwise create an internal one
const internalTable = useReactTable(tableConfig);
const t = table ?? internalTable;
// Handle empty data state
const isEmpty = t.getRowModel().rows.length === 0;
return (
<div className="flex flex-col gap-8">
<div className={cn('flex flex-col gap-8', className)}>
<div className="w-full overflow-x-auto">
<table className="w-full min-w-full text-base">
<thead className="bg-primary-50 mb-3 text-left text-nowrap">
{table.getHeaderGroups().map((headerGroup) => (
{t.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className={cn("py-4 px-5 font-normal first:rounded-l-lg last:rounded-r-lg", header?.column?.columnDef?.meta?.headerClassName)}
onClick={
header.column.getCanSort()
? header.column.getToggleSortingHandler()
: undefined
}
className={cn(
'py-4 px-5 font-normal first:rounded-l-lg last:rounded-r-lg',
header.column.getCanSort() &&
'cursor-pointer select-none hover:bg-primary-100 transition-colors',
header?.column?.columnDef?.meta?.headerClassName
)}
>
{header.isPlaceholder
? null
@@ -59,28 +115,58 @@ export const DataTable = <T extends RowData>({
header.column.columnDef.header,
header.getContext()
)}
{header.column.getCanSort() && (
<span className="ml-2 text-xs text-gray-500">
{header.column.getIsSorted() === 'asc' && '▲'}
{header.column.getIsSorted() === 'desc' && '▼'}
{!header.column.getIsSorted() && <span></span>}
</span>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="bg-primary-100 odd:bg-white">
{row.getVisibleCells().map((cell, index) => (
<td
key={cell.id}
className={cn("py-3 px-5 first:rounded-l-lg last:rounded-r-lg", cell?.column?.columnDef?.meta?.cellClassName)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
{isEmpty ? (
<tr>
<td
colSpan={t.getAllColumns().length}
className="py-8 px-5 text-center text-neutral-500"
>
No data available
</td>
</tr>
))}
) : (
t.getRowModel().rows.map((row, rowIndex) => (
<tr
key={row.id}
className={cn(
'hover:bg-primary-50 transition-colors',
rowIndex % 2 === 0 ? 'bg-white' : 'bg-primary-100'
)}
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className={cn(
'py-3 px-5 first:rounded-l-lg last:rounded-r-lg',
cell?.column?.columnDef?.meta?.cellClassName
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination table={table} />
<Pagination table={t} />
</div>
);
};