Compare commits
10
Commits
d5b267332f
...
6d3e2470c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d3e2470c8 | ||
|
|
48411d3a08 | ||
|
|
426b71f341 | ||
|
|
06c69cb3c5 | ||
|
|
81999aa044 | ||
|
|
d83a6d9ec7 | ||
|
|
63dfb7299f | ||
|
|
53079fe868 | ||
|
|
2944a2b98a | ||
|
|
79f087e2fc |
No files matched your search
+9
-2
@@ -4,7 +4,7 @@
|
||||
dist
|
||||
tmp
|
||||
out-tsc
|
||||
|
||||
docs/FigmaImage/
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
@@ -72,4 +72,11 @@ out
|
||||
result
|
||||
|
||||
.claude/worktrees
|
||||
.claude/settings.local.json
|
||||
.claude/settings.local.json
|
||||
|
||||
# Screenshots and Test Reports
|
||||
**/screenshots
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache
|
||||
@@ -1 +1,2 @@
|
||||
VITE_API_URL=
|
||||
VITE_GITHUB_CLIENT_ID=
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as React from 'react';
|
||||
import type { Row, Table } from '@tanstack/react-table';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Checkbox,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
|
||||
/** Renders a header checkbox that toggles all rows (with indeterminate support). */
|
||||
export function SelectAllCheckbox<T>({ table }: { table: Table<T> }) {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllRowsSelected()
|
||||
? true
|
||||
: table.getIsSomeRowsSelected()
|
||||
? 'indeterminate'
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(v) =>
|
||||
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
|
||||
}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Per-row checkbox. */
|
||||
export function RowSelectCheckbox<T>({ row }: { row: Row<T> }) {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DeleteConfirmProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export function DeleteConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
title = 'Hapus data ini?',
|
||||
description = 'Tindakan ini tidak dapat dibatalkan. Data akan dihapus permanen.',
|
||||
}: DeleteConfirmProps) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Batal</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Hapus
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
'use client';
|
||||
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { useLocation, useNavigate } from '@tanstack/react-router';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
UsersRound,
|
||||
ClipboardCheck,
|
||||
BookOpen,
|
||||
MessageSquare,
|
||||
MessageCircle,
|
||||
Calendar,
|
||||
CalendarClock,
|
||||
Settings,
|
||||
BarChart3,
|
||||
RefreshCcw,
|
||||
Inbox,
|
||||
UserCog,
|
||||
UserPlus,
|
||||
ShieldCheck,
|
||||
KeyRound,
|
||||
User,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
type MenuLink = {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
};
|
||||
|
||||
type MenuGroup = {
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
children: MenuLink[];
|
||||
};
|
||||
|
||||
type MenuItem = MenuLink | MenuGroup;
|
||||
|
||||
const MENUS: MenuItem[] = [
|
||||
{
|
||||
label: 'Hackathon',
|
||||
icon: BarChart3,
|
||||
children: [
|
||||
{ label: 'Dashboard', href: '/hackathon-dashboard', icon: LayoutDashboard },
|
||||
{ label: 'Users', href: '/hackathon-users', icon: Users },
|
||||
{ label: 'Teams', href: '/hackathon-teams', icon: UsersRound },
|
||||
{ label: 'Submissions', href: '/hackathon-submissions', icon: ClipboardCheck },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Dimentorin',
|
||||
icon: BookOpen,
|
||||
children: [
|
||||
{ label: 'Dashboard', href: '/dashboard-dimentorin', icon: LayoutDashboard },
|
||||
{ label: 'Users', href: '/users-dimentorin', icon: UserCog },
|
||||
{ label: 'Session', href: '/session-dimentorin', icon: CalendarClock },
|
||||
{ label: 'Content & Roadmap', href: '/roadmap-dimentorin', icon: BookOpen },
|
||||
{ label: 'Feedback & Review', href: '/feedback-review-dimentorin', icon: MessageSquare },
|
||||
{ label: 'Settings', href: '/settings-dimentorin', icon: Settings },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Gacha',
|
||||
icon: RefreshCcw,
|
||||
children: [
|
||||
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ label: 'Gacha Roll', href: '/gacha-roll', icon: RefreshCcw },
|
||||
{ label: 'Validasi Transaksi', href: '/transactions', icon: ClipboardCheck },
|
||||
{ label: 'Data Pengiriman', href: '/prizes', icon: Inbox },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'CMS',
|
||||
icon: BookOpen,
|
||||
children: [
|
||||
{ label: 'Events', href: '/cms-events', icon: Calendar },
|
||||
{ label: 'Testimonials', href: '/cms-testimonials', icon: MessageCircle },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const FLAT_MENUS: MenuLink[] = [
|
||||
{ label: 'Permissions', href: '/permissions', icon: ShieldCheck },
|
||||
{ label: 'Roles', href: '/roles', icon: KeyRound },
|
||||
{ label: 'Data Akun', href: '/accounts', icon: User },
|
||||
];
|
||||
|
||||
const isMenuGroup = (item: MenuItem): item is MenuGroup =>
|
||||
(item as MenuGroup).children !== undefined;
|
||||
|
||||
export const BackofficeSidebar: FC = (): ReactElement => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin') {
|
||||
return false;
|
||||
}
|
||||
return location.pathname.includes(path);
|
||||
};
|
||||
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>(() => {
|
||||
const initial: Record<string, boolean> = {};
|
||||
MENUS.forEach((menu) => {
|
||||
if (
|
||||
isMenuGroup(menu) &&
|
||||
menu.children.some((child) => isActive(child.href))
|
||||
) {
|
||||
initial[menu.label] = true;
|
||||
}
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
|
||||
const toggleGroup = (label: string) =>
|
||||
setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] }));
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="offcanvas" variant="inset">
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center justify-center px-2 py-3">
|
||||
<img
|
||||
src="/logos/simple.svg"
|
||||
alt="IMPHNEN Logo"
|
||||
className="h-10 w-auto"
|
||||
/>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{MENUS.map((menu) => {
|
||||
const GroupIcon = menu.icon;
|
||||
const open = !!openGroups[menu.label];
|
||||
const groupHasActive =
|
||||
isMenuGroup(menu) &&
|
||||
menu.children.some((c) => isActive(c.href));
|
||||
return (
|
||||
<SidebarMenuItem key={menu.label}>
|
||||
<SidebarMenuButton
|
||||
onClick={() => toggleGroup(menu.label)}
|
||||
isActive={groupHasActive && !open}
|
||||
className="justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<GroupIcon className="size-4" />
|
||||
<span>{menu.label}</span>
|
||||
</span>
|
||||
{open ? (
|
||||
<ChevronDown className="size-3.5 opacity-60" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 opacity-60" />
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
{isMenuGroup(menu) && open && (
|
||||
<SidebarMenuSub>
|
||||
{menu.children.map((child) => {
|
||||
const ChildIcon = child.icon;
|
||||
return (
|
||||
<SidebarMenuSubItem key={child.href}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={isActive(child.href)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigate({ to: child.href })
|
||||
}
|
||||
className={cn(
|
||||
'w-full text-left',
|
||||
)}
|
||||
>
|
||||
<ChildIcon className="size-4" />
|
||||
<span>{child.label}</span>
|
||||
</button>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>System</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{FLAT_MENUS.map((menu) => {
|
||||
const Icon = menu.icon;
|
||||
return (
|
||||
<SidebarMenuItem key={menu.href}>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive(menu.href)}
|
||||
onClick={() => navigate({ to: menu.href })}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{menu.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap');
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@source "../../../libs/ui/**/*.{ts,tsx}";
|
||||
|
||||
@theme {
|
||||
@@ -96,6 +97,45 @@
|
||||
/* ~10px */
|
||||
--text-label3: 0.677rem;
|
||||
/* ~8px */
|
||||
|
||||
/* shadcn/ui semantic tokens — aliased to existing palette (keep colors consistent) */
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #3d3d3d;
|
||||
--color-card: #ffffff;
|
||||
--color-card-foreground: #3d3d3d;
|
||||
--color-popover: #ffffff;
|
||||
--color-popover-foreground: #3d3d3d;
|
||||
--color-primary: #23a1eb;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-secondary: #e7e7e7;
|
||||
--color-secondary-foreground: #3d3d3d;
|
||||
--color-muted: #f6f6f6;
|
||||
--color-muted-foreground: #6d6d6d;
|
||||
--color-accent: #e1f0fd;
|
||||
--color-accent-foreground: #085f9c;
|
||||
--color-destructive: #ff5242;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
--color-border: #d1d1d1;
|
||||
--color-input: #d1d1d1;
|
||||
--color-ring: #3eb0f2;
|
||||
|
||||
--color-sidebar: #ffffff;
|
||||
--color-sidebar-foreground: #4f4f4f;
|
||||
--color-sidebar-primary: #23a1eb;
|
||||
--color-sidebar-primary-foreground: #ffffff;
|
||||
--color-sidebar-accent: #f0f8ff;
|
||||
--color-sidebar-accent-foreground: #085f9c;
|
||||
--color-sidebar-border: #e7e7e7;
|
||||
--color-sidebar-ring: #3eb0f2;
|
||||
|
||||
--color-chart-1: #23a1eb;
|
||||
--color-chart-2: #35ba43;
|
||||
--color-chart-3: #04acf3;
|
||||
--color-chart-4: #ffed27;
|
||||
--color-chart-5: #ff5242;
|
||||
|
||||
--radius: 8px;
|
||||
--font-sans: 'Bai Jamjuree', sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||
import { QueryProvider } from '@imphnen-frontend-service/utils'
|
||||
@@ -19,10 +18,8 @@ const rootElement = document.getElementById('root')
|
||||
if (!rootElement) throw new Error('Failed to find the root element')
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<QueryProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="top-right" />
|
||||
</QueryProvider>
|
||||
</StrictMode>
|
||||
<QueryProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="top-right" />
|
||||
</QueryProvider>
|
||||
)
|
||||
@@ -1,63 +1,28 @@
|
||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
||||
import { SessionToken } from '@imphnen-frontend-service/service'
|
||||
import { useState } from 'react'
|
||||
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms'
|
||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
|
||||
import { SessionToken } from '@imphnen-frontend-service/service';
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import { BackofficeSidebar } from '../components/sidebar';
|
||||
|
||||
export const Route = createFileRoute('/_authenticated')({
|
||||
beforeLoad: () => {
|
||||
const session = SessionToken.get()
|
||||
const session = SessionToken.get();
|
||||
if (!session?.token?.access_token) {
|
||||
throw redirect({ to: '/auth/login' })
|
||||
throw redirect({ to: '/auth/login' });
|
||||
}
|
||||
},
|
||||
component: AuthenticatedLayout,
|
||||
})
|
||||
});
|
||||
|
||||
function AuthenticatedLayout() {
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
||||
<BackofficeSidebar
|
||||
isOpen={mobileSidebarOpen}
|
||||
onClose={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<header
|
||||
className={
|
||||
'lg:hidden sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' +
|
||||
(mobileSidebarOpen ? 'z-0' : 'z-30')
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700 cursor-pointer"
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-p3 font-semibold text-primary-700">
|
||||
IMPHNEN Backoffice
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
<SidebarProvider>
|
||||
<BackofficeSidebar />
|
||||
<SidebarInset>
|
||||
<Outlet />
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
+10
-5
@@ -1,5 +1,4 @@
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"
|
||||
import { PieLabelProps } from "recharts/types/polar/Pie"
|
||||
|
||||
type ChartProps = {
|
||||
name: string
|
||||
@@ -14,14 +13,20 @@ const chartData: ChartProps[] = [
|
||||
]
|
||||
|
||||
const RADIAN = Math.PI / 180;
|
||||
const renderCustomizedLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent }: PieLabelProps) => {
|
||||
const renderCustomizedLabel = (props: any) => {
|
||||
const cx = props.cx ?? 0
|
||||
const cy = props.cy ?? 0
|
||||
const midAngle = props.midAngle ?? 0
|
||||
const innerRadius = props.innerRadius ?? 0
|
||||
const outerRadius = props.outerRadius ?? 0
|
||||
const percent = props.percent ?? 0
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const x = cx + radius * Math.cos(-(midAngle ?? 0) * RADIAN);
|
||||
const y = cy + radius * Math.sin(-(midAngle ?? 0) * RADIAN);
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
|
||||
return (
|
||||
<text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central">
|
||||
{`${((percent ?? 1) * 100).toFixed(0)}%`}
|
||||
{`${(percent * 100).toFixed(0)}%`}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { Button, Input, Select, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
|
||||
import { Button, Input, NativeSelect as Select, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
|
||||
import { cn } from "@imphnen-frontend-service/utils"
|
||||
import { FC } from "react"
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { Button, Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
|
||||
import { Button, Input, NativeSelect as Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
|
||||
import { cn } from "@imphnen-frontend-service/utils"
|
||||
import { FC } from "react"
|
||||
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import * as React from 'react'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import { Filter as FilterIcon, Search, Pencil } from 'lucide-react';
|
||||
import {
|
||||
FilterOutlined,
|
||||
SearchOutlined,
|
||||
EditOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms'
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Checkbox,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Badge,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import {
|
||||
DataTable,
|
||||
Filter,
|
||||
BackofficeWrapper,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
@@ -15,107 +25,96 @@ import {
|
||||
PaginationState,
|
||||
useReactTable,
|
||||
RowSelectionState,
|
||||
} from '@tanstack/react-table'
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
useUserList,
|
||||
TUsersListItem,
|
||||
} from '@imphnen-frontend-service/service'
|
||||
} from '@imphnen-frontend-service/service';
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/accounts')({
|
||||
component: AccountsPage,
|
||||
})
|
||||
});
|
||||
|
||||
function AccountsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
})
|
||||
|
||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
||||
const [showFilter, setShowFilter] = useState(false)
|
||||
pageSize: 10,
|
||||
});
|
||||
const [rowSelection, setRowSelection] =
|
||||
React.useState<RowSelectionState>({});
|
||||
const [showFilter, setShowFilter] = React.useState(false);
|
||||
|
||||
const { data: usersData, isLoading } = useUserList({
|
||||
search,
|
||||
page: pagination.pageIndex + 1,
|
||||
per_page: pagination.pageSize,
|
||||
})
|
||||
});
|
||||
|
||||
const users: TUsersListItem[] = usersData?.data ?? []
|
||||
const totalItems = usersData?.meta?.total ?? users.length
|
||||
const users: TUsersListItem[] = usersData?.data ?? [];
|
||||
const totalItems = usersData?.meta?.total ?? users.length;
|
||||
|
||||
const columns: ColumnDef<TUsersListItem>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllRowsSelected()
|
||||
? true
|
||||
: table.getIsSomeRowsSelected()
|
||||
? 'indeterminate'
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(v) =>
|
||||
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
|
||||
}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'No',
|
||||
accessorKey: 'id',
|
||||
},
|
||||
{
|
||||
header: 'Nama Lengkap',
|
||||
accessorKey: 'fullname',
|
||||
},
|
||||
{
|
||||
header: 'Email',
|
||||
accessorKey: 'email',
|
||||
},
|
||||
{
|
||||
header: 'Role',
|
||||
accessorKey: 'role',
|
||||
},
|
||||
{ header: 'No', accessorKey: 'id' },
|
||||
{ header: 'Nama Lengkap', accessorKey: 'fullname' },
|
||||
{ header: 'Email', accessorKey: 'email' },
|
||||
{ header: 'Role', accessorKey: 'role' },
|
||||
{
|
||||
header: 'Status',
|
||||
accessorKey: 'is_active',
|
||||
cell: ({ row }) => (
|
||||
<span className={row.original.is_active ? 'text-success-500' : 'text-danger-500'}>
|
||||
<Badge variant={row.original.is_active ? 'success' : 'destructive'}>
|
||||
{row.original.is_active ? 'Aktif' : 'Tidak Aktif'}
|
||||
</span>
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate({ to: '/accounts/$id', params: { id: row.original.id } })
|
||||
e.stopPropagation();
|
||||
navigate({ to: '/accounts/$id', params: { id: row.original.id } });
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<EditOutlined /> Edit
|
||||
<Pencil className="size-3.5" /> Edit
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: users,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
state: { pagination, rowSelection },
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -123,52 +122,65 @@ function AccountsPage() {
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||
manualPagination: true,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">Data Akun</h1>
|
||||
</header>
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
<div className="flex justify-between items-center gap-8 mb-2">
|
||||
<div className="relative w-full">
|
||||
<BackofficeWrapper
|
||||
title="Data Akun"
|
||||
description="Kelola akun pengguna yang terdaftar"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama lengkap, email"
|
||||
className="pl-12 w-full max-h-full"
|
||||
placeholder="Cari nama lengkap atau email…"
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||
<SearchOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex items-center gap-3"
|
||||
disabled
|
||||
onClick={() => setShowFilter(!showFilter)}
|
||||
>
|
||||
<FilterOutlined />
|
||||
Filters
|
||||
</Button>
|
||||
{showFilter && (
|
||||
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
|
||||
<Filter onClose={() => setShowFilter(false)} options={[]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Popover open={showFilter} onOpenChange={setShowFilter}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="secondary" size="md">
|
||||
<FilterIcon className="size-4" />
|
||||
Filters
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-auto p-0">
|
||||
<Filter
|
||||
onClose={() => setShowFilter(false)}
|
||||
options={[
|
||||
{ id: 'all', value: 'all', label: 'Semua' },
|
||||
{ id: 'active', value: 'active', label: 'Aktif' },
|
||||
{ id: 'inactive', value: 'inactive', label: 'Tidak Aktif' },
|
||||
]}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
Memuat data…
|
||||
</div>
|
||||
) : (
|
||||
<DataTable data={users} columns={columns} table={table} />
|
||||
<DataTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
table={table}
|
||||
manualPagination
|
||||
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||
currentPage={pagination.pageIndex + 1}
|
||||
onPageChange={(p) =>
|
||||
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</Fragment>
|
||||
)
|
||||
</CardContent>
|
||||
</Card>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +1,122 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { InputField } from '@imphnen-frontend-service/ui/molecules'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import { InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||
import {
|
||||
useUserList,
|
||||
useUpdateUserById,
|
||||
} from '@imphnen-frontend-service/service'
|
||||
} from '@imphnen-frontend-service/service';
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/accounts_/$id')({
|
||||
component: AccountsEditPage,
|
||||
})
|
||||
});
|
||||
|
||||
function AccountsEditPage() {
|
||||
const { id } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
const updateUser = useUpdateUserById()
|
||||
const { id } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const updateUser = useUpdateUserById();
|
||||
|
||||
const { data: usersData, isLoading } = useUserList({ search: '', per_page: 100 })
|
||||
const user = usersData?.data?.find((u) => u.id === id)
|
||||
const { data: usersData, isLoading } = useUserList({
|
||||
search: '',
|
||||
per_page: 100,
|
||||
});
|
||||
const user = usersData?.data?.find((u) => u.id === id);
|
||||
|
||||
const [fullName, setFullName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [fullName, setFullName] = React.useState('');
|
||||
const [email, setEmail] = React.useState('');
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (user) {
|
||||
setFullName(user.fullname)
|
||||
setEmail(user.email)
|
||||
setFullName(user.fullname);
|
||||
setEmail(user.email);
|
||||
}
|
||||
}, [user])
|
||||
}, [user]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await updateUser.mutateAsync({ id, data: { fullname: fullName, email } })
|
||||
toast.success('Data akun berhasil diperbarui')
|
||||
navigate({ to: '/accounts' })
|
||||
await updateUser.mutateAsync({
|
||||
id,
|
||||
data: { fullname: fullName, email },
|
||||
});
|
||||
toast.success('Data akun berhasil diperbarui');
|
||||
navigate({ to: '/accounts' });
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
toast.error('Data akun gagal diperbarui')
|
||||
console.log(error);
|
||||
toast.error('Data akun gagal diperbarui');
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main className="w-full px-[48px] py-[40px]">
|
||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
<div className="max-w-2xl mx-auto w-full">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
onClick={() => navigate({ to: '/accounts' })}
|
||||
className="text-primary-500 hover:text-primary-600"
|
||||
>
|
||||
<ArrowLeftOutlined className="text-[20px]" />
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Edit Data Akun</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<InputField
|
||||
label="Nama Lengkap"
|
||||
type="text"
|
||||
placeholder="Masukkan Nama Lengkap"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<InputField
|
||||
label="Email"
|
||||
type="text"
|
||||
placeholder="Masukkan Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Perbarui Data
|
||||
</Button>
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => navigate({ to: '/accounts' })}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BackofficeWrapper title="Edit Data Akun">
|
||||
<div className="mx-auto w-full max-w-2xl">
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onClick={() => navigate({ to: '/accounts' })}
|
||||
className="mb-4 -ml-2"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Kembali
|
||||
</Button>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Edit Data Akun</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
Memuat data…
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<InputField
|
||||
label="Nama Lengkap"
|
||||
type="text"
|
||||
placeholder="Masukkan nama lengkap"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
size="md"
|
||||
/>
|
||||
<InputField
|
||||
label="Email"
|
||||
type="text"
|
||||
placeholder="Masukkan email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
onClick={() => navigate({ to: '/accounts' })}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={handleSubmit}
|
||||
disabled={updateUser.isPending || isLoading}
|
||||
>
|
||||
{updateUser.isPending ? 'Menyimpan…' : 'Perbarui Data'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,27 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
||||
import {
|
||||
SearchOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Checkbox,
|
||||
Input,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import {
|
||||
DataTable,
|
||||
BackofficeWrapper,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
@@ -15,76 +29,77 @@ import {
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
useEventList,
|
||||
useDeleteEvent,
|
||||
TEventsListItem,
|
||||
} from '@imphnen-frontend-service/service'
|
||||
import React from 'react'
|
||||
import { toast } from 'sonner'
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/cms-events')({
|
||||
component: CmsEventsPage,
|
||||
})
|
||||
});
|
||||
|
||||
function CmsEventsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [search, setSearch] = useState('')
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
})
|
||||
|
||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
||||
pageSize: 10,
|
||||
});
|
||||
const [rowSelection, setRowSelection] =
|
||||
React.useState<RowSelectionState>({});
|
||||
|
||||
const { data: eventsData, isLoading } = useEventList({
|
||||
search,
|
||||
page: pagination.pageIndex + 1,
|
||||
per_page: pagination.pageSize,
|
||||
})
|
||||
const deleteEvent = useDeleteEvent()
|
||||
});
|
||||
const deleteEvent = useDeleteEvent();
|
||||
|
||||
const events: TEventsListItem[] = eventsData?.data ?? []
|
||||
const totalItems = eventsData?.meta?.total ?? events.length
|
||||
const events: TEventsListItem[] = eventsData?.data ?? [];
|
||||
const totalItems = eventsData?.meta?.total ?? events.length;
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteEvent.mutateAsync(id)
|
||||
toast.success('Data event berhasil dihapus')
|
||||
setDeleteId(null)
|
||||
await deleteEvent.mutateAsync(id);
|
||||
toast.success('Data event berhasil dihapus');
|
||||
setDeleteId(null);
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
toast.error('Data event gagal dihapus')
|
||||
console.log(error);
|
||||
toast.error('Data event gagal dihapus');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<TEventsListItem>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllRowsSelected()
|
||||
? true
|
||||
: table.getIsSomeRowsSelected()
|
||||
? 'indeterminate'
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(v) =>
|
||||
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
|
||||
}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{ header: 'Name', accessorKey: 'name' },
|
||||
{
|
||||
header: 'Location',
|
||||
accessorKey: 'location',
|
||||
@@ -112,81 +127,49 @@ function CmsEventsPage() {
|
||||
header: 'Online',
|
||||
accessorKey: 'is_online',
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-label2 font-medium ${
|
||||
row.original.is_online
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Badge variant={row.original.is_online ? 'success' : 'secondary'}>
|
||||
{row.original.is_online ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-[8px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate({ to: '/cms-events/$id', params: { id: row.original.id } })
|
||||
e.stopPropagation();
|
||||
navigate({
|
||||
to: '/cms-events/$id',
|
||||
params: { id: row.original.id },
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<EditOutlined /> Update
|
||||
<Pencil className="size-3.5" />
|
||||
Update
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteId(row.original.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
{deleteId === row.original.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-label2 text-neutral-500">Yakin?</span>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(row.original.id)
|
||||
}}
|
||||
>
|
||||
Ya
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteId(null)
|
||||
}}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteId(row.original.id)
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<DeleteOutlined /> Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: events,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
state: { pagination, rowSelection },
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -194,53 +177,74 @@ function CmsEventsPage() {
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||
manualPagination: true,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">CMS Events</h1>
|
||||
</header>
|
||||
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
<div className="flex justify-between items-center gap-8 mb-2">
|
||||
<div className="relative w-full">
|
||||
<BackofficeWrapper title="CMS Events" description="Kelola event komunitas">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama event"
|
||||
className="pl-12 w-full max-h-full"
|
||||
placeholder="Cari nama event…"
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||
<SearchOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex gap-3 text-nowrap"
|
||||
onClick={() => navigate({ to: '/cms-events/create' })}
|
||||
>
|
||||
<PlusOutlined />
|
||||
Tambah Event
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate({ to: '/cms-events/create' })}
|
||||
size="md"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Tambah Event
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
Memuat data…
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={events}
|
||||
columns={columns}
|
||||
pageSize={9}
|
||||
table={table}
|
||||
manualPagination
|
||||
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||
currentPage={pagination.pageIndex + 1}
|
||||
onPageChange={(p) =>
|
||||
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</Fragment>
|
||||
)
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog
|
||||
open={!!deleteId}
|
||||
onOpenChange={(o) => !o && setDeleteId(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Hapus event ini?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tindakan ini tidak dapat dibatalkan. Event akan dihapus permanen.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Batal</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteId && handleDelete(deleteId)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Hapus
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,26 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
||||
import {
|
||||
SearchOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Checkbox,
|
||||
Input,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import {
|
||||
DataTable,
|
||||
BackofficeWrapper,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
@@ -15,86 +28,84 @@ import {
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
useTestimonialList,
|
||||
useDeleteTestimonial,
|
||||
TTestimonialsListItem,
|
||||
} from '@imphnen-frontend-service/service'
|
||||
import React from 'react'
|
||||
import { toast } from 'sonner'
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/cms-testimonials')({
|
||||
component: CmsTestimonialsPage,
|
||||
})
|
||||
});
|
||||
|
||||
function CmsTestimonialsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [search, setSearch] = useState('')
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
})
|
||||
|
||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
||||
pageSize: 10,
|
||||
});
|
||||
const [rowSelection, setRowSelection] =
|
||||
React.useState<RowSelectionState>({});
|
||||
|
||||
const { data: testimonialsData, isLoading } = useTestimonialList({
|
||||
search,
|
||||
page: pagination.pageIndex + 1,
|
||||
per_page: pagination.pageSize,
|
||||
})
|
||||
const deleteTestimonial = useDeleteTestimonial()
|
||||
});
|
||||
const deleteTestimonial = useDeleteTestimonial();
|
||||
|
||||
const testimonials: TTestimonialsListItem[] = testimonialsData?.data ?? []
|
||||
const totalItems = testimonialsData?.meta?.total ?? testimonials.length
|
||||
const testimonials: TTestimonialsListItem[] = testimonialsData?.data ?? [];
|
||||
const totalItems = testimonialsData?.meta?.total ?? testimonials.length;
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteTestimonial.mutateAsync(id)
|
||||
toast.success('Data testimonial berhasil dihapus')
|
||||
setDeleteId(null)
|
||||
await deleteTestimonial.mutateAsync(id);
|
||||
toast.success('Data testimonial berhasil dihapus');
|
||||
setDeleteId(null);
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
toast.error('Data testimonial gagal dihapus')
|
||||
console.log(error);
|
||||
toast.error('Data testimonial gagal dihapus');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<TTestimonialsListItem>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllRowsSelected()
|
||||
? true
|
||||
: table.getIsSomeRowsSelected()
|
||||
? 'indeterminate'
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(v) =>
|
||||
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
|
||||
}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'User',
|
||||
accessorKey: 'user_fullname',
|
||||
},
|
||||
{
|
||||
header: 'Role',
|
||||
accessorKey: 'role',
|
||||
},
|
||||
{ header: 'User', accessorKey: 'user_fullname' },
|
||||
{ header: 'Role', accessorKey: 'role' },
|
||||
{
|
||||
header: 'Content',
|
||||
accessorKey: 'content',
|
||||
cell: ({ row }) => {
|
||||
const content = row.original.content
|
||||
return content.length > 80 ? `${content.substring(0, 80)}...` : content
|
||||
const content = row.original.content;
|
||||
return content.length > 80
|
||||
? `${content.substring(0, 80)}…`
|
||||
: content;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -110,67 +121,41 @@ function CmsTestimonialsPage() {
|
||||
{
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-[8px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate({ to: '/cms-testimonials/$id', params: { id: row.original.id } })
|
||||
e.stopPropagation();
|
||||
navigate({
|
||||
to: '/cms-testimonials/$id',
|
||||
params: { id: row.original.id },
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<EditOutlined /> Update
|
||||
<Pencil className="size-3.5" />
|
||||
Update
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteId(row.original.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
{deleteId === row.original.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-label2 text-neutral-500">Yakin?</span>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(row.original.id)
|
||||
}}
|
||||
>
|
||||
Ya
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteId(null)
|
||||
}}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteId(row.original.id)
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<DeleteOutlined /> Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: testimonials,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
state: { pagination, rowSelection },
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -178,53 +163,78 @@ function CmsTestimonialsPage() {
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||
manualPagination: true,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">CMS Testimonials</h1>
|
||||
</header>
|
||||
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
<div className="flex justify-between items-center gap-8 mb-2">
|
||||
<div className="relative w-full">
|
||||
<BackofficeWrapper
|
||||
title="CMS Testimonials"
|
||||
description="Kelola testimonial pengguna"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama user"
|
||||
className="pl-12 w-full max-h-full"
|
||||
placeholder="Cari nama user…"
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||
<SearchOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex gap-3 text-nowrap"
|
||||
onClick={() => navigate({ to: '/cms-testimonials/create' })}
|
||||
>
|
||||
<PlusOutlined />
|
||||
Tambah Testimonial
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate({ to: '/cms-testimonials/create' })}
|
||||
size="md"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Tambah Testimonial
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
Memuat data…
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={testimonials}
|
||||
columns={columns}
|
||||
pageSize={9}
|
||||
table={table}
|
||||
manualPagination
|
||||
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||
currentPage={pagination.pageIndex + 1}
|
||||
onPageChange={(p) =>
|
||||
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</Fragment>
|
||||
)
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog
|
||||
open={!!deleteId}
|
||||
onOpenChange={(o) => !o && setDeleteId(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Hapus testimonial ini?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tindakan ini tidak dapat dibatalkan. Testimonial akan dihapus
|
||||
permanen.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Batal</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteId && handleDelete(deleteId)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Hapus
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,152 +1,212 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms'
|
||||
import { For } from '@imphnen-frontend-service/utils'
|
||||
import { ReactElement } from 'react'
|
||||
import { UserGrowthChart } from './_components/dashboard-dimentorin/chart/user-growth'
|
||||
import { SessionStatusChart } from './_components/dashboard-dimentorin/chart/session-status'
|
||||
import { useMentorList, useUserList, useMySessions } from '@imphnen-frontend-service/service'
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import { Users, UserCog, CalendarClock, Activity, CircleCheck } from 'lucide-react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { UserGrowthChart } from './_components/dashboard-dimentorin/chart/user-growth';
|
||||
import { SessionStatusChart } from './_components/dashboard-dimentorin/chart/session-status';
|
||||
import {
|
||||
useMentorList,
|
||||
useUserList,
|
||||
useMySessions,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/dashboard-dimentorin')({
|
||||
component: DashboardDimentorinPage,
|
||||
})
|
||||
});
|
||||
|
||||
function DashboardDimentorinPage(): ReactElement {
|
||||
const { data: mentorData } = useMentorList({ per_page: 5, sort_by: 'rating', order: 'desc' })
|
||||
const { data: userData } = useUserList({ per_page: 1 })
|
||||
const { data: sessionsData } = useMySessions()
|
||||
type StatCardProps = {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
};
|
||||
|
||||
const totalMentors = mentorData?.meta?.total ?? 0
|
||||
const totalUsers = userData?.meta?.total ?? 0
|
||||
const totalSessions = sessionsData?.total ?? 0
|
||||
const topMentors = mentorData?.data ?? []
|
||||
function StatCard({ icon: Icon, label, value }: StatCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-4 pt-6">
|
||||
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary-100 text-primary-600">
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-2xl font-semibold leading-tight text-foreground">
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const overviewStats = [
|
||||
{ label: 'Total Users', value: totalUsers },
|
||||
{ label: 'Total Mentors', value: totalMentors },
|
||||
{ label: 'Total Sessions', value: totalSessions },
|
||||
{ label: 'Active Mentors', value: topMentors.filter((m) => m.status === 'active').length },
|
||||
{ label: 'Completed Sessions', value: sessionsData?.sessions?.filter((s) => s.status === 'completed').length ?? 0 },
|
||||
]
|
||||
function DashboardDimentorinPage() {
|
||||
const { data: mentorData } = useMentorList({
|
||||
per_page: 5,
|
||||
sort_by: 'rating',
|
||||
order: 'desc',
|
||||
});
|
||||
const { data: userData } = useUserList({ per_page: 1 });
|
||||
const { data: sessionsData } = useMySessions();
|
||||
|
||||
const totalMentors = mentorData?.meta?.total ?? 0;
|
||||
const totalUsers = userData?.meta?.total ?? 0;
|
||||
const totalSessions = sessionsData?.total ?? 0;
|
||||
const topMentors = mentorData?.data ?? [];
|
||||
const activeMentors = topMentors.filter((m) => m.status === 'active').length;
|
||||
const completedSessions =
|
||||
sessionsData?.sessions?.filter((s) => s.status === 'completed').length ?? 0;
|
||||
|
||||
const topTopics = React.useMemo(() => {
|
||||
const sessions = sessionsData?.sessions ?? [];
|
||||
const topicCount: Record<string, number> = {};
|
||||
sessions.forEach((s) => {
|
||||
topicCount[s.topic] = (topicCount[s.topic] ?? 0) + 1;
|
||||
});
|
||||
return Object.entries(topicCount)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5);
|
||||
}, [sessionsData]);
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="Dimentorin.dev">
|
||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-5">Overview</h1>
|
||||
<BackofficeWrapper
|
||||
title="Dimentorin Overview"
|
||||
description="Ringkasan metrik platform mentoring"
|
||||
>
|
||||
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<StatCard icon={Users} label="Total Users" value={totalUsers} />
|
||||
<StatCard icon={UserCog} label="Total Mentors" value={totalMentors} />
|
||||
<StatCard
|
||||
icon={CalendarClock}
|
||||
label="Total Sessions"
|
||||
value={totalSessions}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label="Active Mentors"
|
||||
value={activeMentors}
|
||||
/>
|
||||
<StatCard
|
||||
icon={CircleCheck}
|
||||
label="Completed Sessions"
|
||||
value={completedSessions}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="space-y-14">
|
||||
<div>
|
||||
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
|
||||
Overview
|
||||
</Button>
|
||||
<section className="grid grid-cols-1 gap-4 lg:grid-cols-7">
|
||||
<Card className="lg:col-span-5">
|
||||
<CardHeader>
|
||||
<CardTitle>User Growth</CardTitle>
|
||||
<CardDescription>Pertumbuhan user dari waktu ke waktu</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UserGrowthChart />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Session Status</CardTitle>
|
||||
<CardDescription>Distribusi status sesi</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SessionStatusChart />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-5 gap-5">
|
||||
<For data={overviewStats}>
|
||||
{(stat, index) => (
|
||||
<div key={index} className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">{stat.value}</h3>
|
||||
<p className="text-neutral-400 text-p3">{stat.label}</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
|
||||
Trends & Analytics
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-7 gap-x-5">
|
||||
<div className="bg-white px-6 py-4 rounded-lg col-span-5">
|
||||
<div className="flex items-center justify-between mb-7">
|
||||
<h2 className="font-semibold text-p3 text-neutral-700">User Growth</h2>
|
||||
<div></div>
|
||||
</div>
|
||||
<UserGrowthChart />
|
||||
</div>
|
||||
<div className="bg-white px-6 py-4 rounded-lg col-span-2">
|
||||
<h2 className="font-semibold text-p3 text-neutral-700 mb-7">Session Status</h2>
|
||||
<SessionStatusChart />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-5">
|
||||
<div className="bg-white px-7 py-4 rounded-lg">
|
||||
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top 5 Mentors</h2>
|
||||
|
||||
<div>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-label1 bg-primary-50 text-left rounded-full">
|
||||
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
|
||||
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
|
||||
<th className="font-medium py-4 px-5 rounded-r-lg">Avg Rating</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{topMentors.slice(0, 5).map((mentor, index) => (
|
||||
<tr key={mentor.id} className="shadow rounded-lg">
|
||||
<td className="py-4 px-5">{index + 1}</td>
|
||||
<td className="py-4 px-5">{mentor.fullname ?? '-'}</td>
|
||||
<td className="py-4 px-5">{mentor.rating?.toFixed(1) ?? '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{topMentors.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="py-4 px-5 text-center text-neutral-400">Belum ada data</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white px-6 py-4 rounded-lg">
|
||||
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top Booked Mentoring Topics</h2>
|
||||
|
||||
<div>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-label1 bg-primary-50 text-left font-medium">
|
||||
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
|
||||
<th className="font-medium py-4 px-5 w-3/5">Topik</th>
|
||||
<th className="font-medium py-4 px-5 rounded-r-lg">Total Sesi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{(() => {
|
||||
const sessions = sessionsData?.sessions ?? []
|
||||
const topicCount: Record<string, number> = {}
|
||||
sessions.forEach((s) => {
|
||||
topicCount[s.topic] = (topicCount[s.topic] ?? 0) + 1
|
||||
})
|
||||
const topTopics = Object.entries(topicCount)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
if (topTopics.length === 0) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={3} className="py-4 px-5 text-center text-neutral-400">Belum ada data</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
return topTopics.map(([topic, count], index) => (
|
||||
<tr key={topic} className="shadow rounded-lg">
|
||||
<td className="py-4 px-5">{index + 1}</td>
|
||||
<td className="py-4 px-5">{topic}</td>
|
||||
<td className="py-4 px-5">{count}</td>
|
||||
</tr>
|
||||
<section className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top 5 Mentors</CardTitle>
|
||||
<CardDescription>Mentor dengan rating tertinggi</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border border-neutral-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[10%]">No.</TableHead>
|
||||
<TableHead>Nama Lengkap</TableHead>
|
||||
<TableHead>Avg Rating</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{topMentors.length === 0 ? (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
Belum ada data
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
topMentors.slice(0, 5).map((mentor, index) => (
|
||||
<TableRow key={mentor.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>{mentor.fullname ?? '-'}</TableCell>
|
||||
<TableCell>
|
||||
{mentor.rating?.toFixed(1) ?? '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
})()}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Booked Topics</CardTitle>
|
||||
<CardDescription>Topik mentoring paling banyak dibooking</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border border-neutral-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[10%]">No.</TableHead>
|
||||
<TableHead>Topik</TableHead>
|
||||
<TableHead>Total Sesi</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{topTopics.length === 0 ? (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
Belum ada data
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
topTopics.map(([topic, count], index) => (
|
||||
<TableRow key={topic}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>{topic}</TableCell>
|
||||
<TableCell>{count}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</BackofficeWrapper>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,195 +1,185 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
UsergroupAddOutlined,
|
||||
UsergroupDeleteOutlined,
|
||||
UserSwitchOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { Fragment, useState } from 'react'
|
||||
Plus,
|
||||
UsersRound,
|
||||
UserMinus,
|
||||
UserCog,
|
||||
RefreshCcw,
|
||||
Pencil,
|
||||
Trash2,
|
||||
MoreHorizontal,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||
import {
|
||||
useUserList,
|
||||
useGachaItemList,
|
||||
useDeleteGachaItem,
|
||||
TGachaItemDto,
|
||||
} from '@imphnen-frontend-service/service'
|
||||
import { toast } from 'sonner'
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
import { DeleteConfirmDialog } from '../../components/list-helpers';
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/dashboard')({
|
||||
component: DashboardPage,
|
||||
})
|
||||
});
|
||||
|
||||
type StatCardProps = {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
};
|
||||
|
||||
function StatCard({ icon: Icon, label, value }: StatCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-4 pt-6">
|
||||
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary-100 text-primary-600">
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-2xl font-semibold leading-tight text-foreground">
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const navigate = useNavigate();
|
||||
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||
|
||||
const { data: usersData } = useUserList({ per_page: 1 })
|
||||
const { data: gachaItemsData } = useGachaItemList({ per_page: 9 })
|
||||
const deleteItem = useDeleteGachaItem()
|
||||
const { data: usersData } = useUserList({ per_page: 1 });
|
||||
const { data: gachaItemsData } = useGachaItemList({ per_page: 9 });
|
||||
const deleteItem = useDeleteGachaItem();
|
||||
|
||||
const totalUsers = usersData?.meta?.total ?? 0
|
||||
const gachaItems: TGachaItemDto[] = gachaItemsData?.data ?? []
|
||||
const totalUsers = usersData?.meta?.total ?? 0;
|
||||
const gachaItems: TGachaItemDto[] = gachaItemsData?.data ?? [];
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteItem.mutateAsync(id)
|
||||
toast.success('Item berhasil dihapus')
|
||||
setDeleteId(null)
|
||||
await deleteItem.mutateAsync(id);
|
||||
toast.success('Item berhasil dihapus');
|
||||
setDeleteId(null);
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
toast.error('Item gagal dihapus')
|
||||
console.log(error);
|
||||
toast.error('Item gagal dihapus');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">Dashboard</h1>
|
||||
</header>
|
||||
<BackofficeWrapper
|
||||
title="Gacha Dashboard"
|
||||
description="Ringkasan statistik & daftar item gacha"
|
||||
>
|
||||
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
icon={UsersRound}
|
||||
label="Participants"
|
||||
value={totalUsers.toLocaleString('id-ID')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={RefreshCcw}
|
||||
label="Gacha Items"
|
||||
value={(gachaItemsData?.meta?.total ?? 0).toLocaleString('id-ID')}
|
||||
/>
|
||||
<StatCard icon={UserCog} label="Redeem" value="—" />
|
||||
<StatCard icon={UserMinus} label="Inactive Users" value="—" />
|
||||
</section>
|
||||
|
||||
<div className="flex justify-between gap-[40px] p-8 bg-white rounded-md">
|
||||
<div className="w-full flex flex-col gap-[40px]">
|
||||
<section>
|
||||
<h2 className="text-p2 font-medium text-primary-500 mb-8">
|
||||
Summary
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||
<UsergroupAddOutlined className="text-[20px]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-p1 font-semibold">{totalUsers}</h3>
|
||||
<p className="text-label1 text-neutral-500">Participants</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||
<ReloadOutlined className="text-[20px]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-p1 font-semibold">{gachaItemsData?.meta?.total ?? 0}</h3>
|
||||
<p className="text-label1 text-neutral-500">
|
||||
Gacha Items
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||
<UserSwitchOutlined className="text-[20px]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-p1 font-semibold">-</h3>
|
||||
<p className="text-label1 text-neutral-500">Redeem</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||
<UsergroupDeleteOutlined className="text-[20px]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-p1 font-semibold">-</h3>
|
||||
<p className="text-label1 text-neutral-500">
|
||||
Inactive Users
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-p2 font-medium text-primary-500">
|
||||
Gacha Items
|
||||
</h2>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="items-end gap-3"
|
||||
onClick={() => navigate({ to: '/dashboard/create' })}
|
||||
>
|
||||
<span>Tambah Item</span>
|
||||
<PlusOutlined className="text-[16px]" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 max-h-140 overflow-auto">
|
||||
{gachaItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white overflow-clip rounded-lg shadow-sm flex justify-between border border-neutral-100"
|
||||
>
|
||||
<div className="flex flex-col py-4 px-6 gap-[8px]">
|
||||
<div>
|
||||
<h3 className="text-p3 text-primary-500 font-medium">
|
||||
{item.name}
|
||||
</h3>
|
||||
<div className="flex items-center justify-start gap-10 text-label2 text-gray-500 mt-1">
|
||||
<span>{item.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-start gap-2">
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="text-[10px] text-neutral-500 p-0 font-normal hover:bg-transparent hover:text-primary-500"
|
||||
onClick={() => navigate({ to: '/dashboard/$id', params: { id: item.id } })}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
{deleteId === item.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-neutral-400">Yakin?</span>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="text-[10px] text-red-500 p-0 font-normal hover:bg-transparent hover:text-red-700"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
>
|
||||
Ya
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="text-[10px] text-neutral-500 p-0 font-normal hover:bg-transparent"
|
||||
onClick={() => setDeleteId(null)}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="text-[10px] text-red-500 p-0 font-normal hover:bg-transparent hover:text-red-700"
|
||||
onClick={() => setDeleteId(item.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img src="gacha-clip.webp" alt={item.name} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Gacha Items</CardTitle>
|
||||
<CardDescription>
|
||||
Daftar item yang tersedia di gacha
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size="md"
|
||||
onClick={() => navigate({ to: '/dashboard/create' })}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Tambah Item
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{gachaItems.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
Belum ada item gacha.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{gachaItems.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="flex items-start justify-between gap-3 rounded-md border border-neutral-200 p-4 transition-colors hover:border-primary-200 hover:bg-primary-50/40"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-sm font-semibold text-primary-700">
|
||||
{item.name}
|
||||
</h3>
|
||||
<p className="mt-0.5 font-mono text-xs text-muted-foreground">
|
||||
{item.id}
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="text" size="icon" aria-label="Actions">
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
navigate({
|
||||
to: '/dashboard/$id',
|
||||
params: { id: item.id },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Pencil />
|
||||
<span>Edit</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => setDeleteId(item.id)}
|
||||
>
|
||||
<Trash2 />
|
||||
<span>Hapus</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<img
|
||||
src="gacha.webp"
|
||||
alt=""
|
||||
className="rounded-lg hidden xl:block xl:min-w-[436px] h-auto object-cover"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</Fragment>
|
||||
)
|
||||
<DeleteConfirmDialog
|
||||
open={!!deleteId}
|
||||
onOpenChange={(o) => !o && setDeleteId(null)}
|
||||
onConfirm={() => deleteId && handleDelete(deleteId)}
|
||||
title="Hapus item gacha ini?"
|
||||
/>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +1,91 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { BackofficeWrapper, DataTable } from '@imphnen-frontend-service/ui/organisms'
|
||||
import { cn, For } from '@imphnen-frontend-service/utils'
|
||||
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from '@tanstack/react-table'
|
||||
import { ReactElement, useState } from 'react'
|
||||
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service'
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import { Search, MessageSquare } from 'lucide-react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service';
|
||||
import {
|
||||
SelectAllCheckbox,
|
||||
RowSelectCheckbox,
|
||||
} from '../../components/list-helpers';
|
||||
|
||||
const TABS = {
|
||||
MENTORING: 'Mentoring',
|
||||
PLATFORM: 'Platform'
|
||||
} as const
|
||||
type Tabs = typeof TABS[keyof typeof TABS]
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/feedback-review-dimentorin')({
|
||||
export const Route = createFileRoute(
|
||||
'/_authenticated/feedback-review-dimentorin'
|
||||
)({
|
||||
component: FeedbackReviewDimentorinPage,
|
||||
})
|
||||
});
|
||||
|
||||
function FeedbackReviewDimentorinPage(): ReactElement {
|
||||
const [activeTab, setActiveTab] = useState<Tabs>(TABS.MENTORING)
|
||||
function FeedbackReviewDimentorinPage() {
|
||||
const [activeTab, setActiveTab] = React.useState<'mentoring' | 'platform'>(
|
||||
'mentoring'
|
||||
);
|
||||
const [ratingFilter, setRatingFilter] = React.useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = React.useState<string>('all');
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
const [rowSelection, setRowSelection] =
|
||||
React.useState<RowSelectionState>({});
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
})
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const { data: sessionsData, isLoading } = useMySessions(
|
||||
activeTab === TABS.MENTORING ? { status: 'completed' } : undefined
|
||||
)
|
||||
activeTab === 'mentoring' ? { status: 'completed' } : undefined
|
||||
);
|
||||
|
||||
const sessions: TSessionListItem[] = activeTab === TABS.MENTORING
|
||||
? (sessionsData?.sessions ?? [])
|
||||
: []
|
||||
const totalItems = activeTab === TABS.MENTORING
|
||||
? (sessionsData?.total ?? sessions.length)
|
||||
: 0
|
||||
const allSessions: TSessionListItem[] =
|
||||
activeTab === 'mentoring' ? (sessionsData?.sessions ?? []) : [];
|
||||
|
||||
const sessions = React.useMemo(() => {
|
||||
return allSessions.filter((s) => {
|
||||
if (statusFilter !== 'all') {
|
||||
const hasRating = !!s.rating;
|
||||
if (statusFilter === 'done' && !hasRating) return false;
|
||||
if (statusFilter === 'todo' && hasRating) return false;
|
||||
}
|
||||
if (ratingFilter !== 'all' && String(s.rating ?? '') !== ratingFilter)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}, [allSessions, statusFilter, ratingFilter]);
|
||||
|
||||
const totalItems =
|
||||
activeTab === 'mentoring' ? (sessionsData?.total ?? sessions.length) : 0;
|
||||
|
||||
const columns: ColumnDef<TSessionListItem>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
meta: { cellClassName: cn('w-20') },
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
meta: { cellClassName: cn('w-10') },
|
||||
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
@@ -81,39 +110,29 @@ function FeedbackReviewDimentorinPage(): ReactElement {
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => {
|
||||
const hasRating = !!row.original.rating
|
||||
const hasRating = !!row.original.rating;
|
||||
return (
|
||||
<div className={`py-2 px-4 rounded-md text-center ${hasRating ? 'bg-success-200 text-success-500' : 'bg-primary-200 text-primary-500'}`}>
|
||||
<Badge variant={hasRating ? 'success' : 'info'}>
|
||||
{hasRating ? 'Done' : 'To Do'}
|
||||
</div>
|
||||
)
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
meta: { cellClassName: cn('w-72') },
|
||||
cell: () => (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
className="flex items-center gap-2 w-max"
|
||||
>
|
||||
<SearchOutlined className="text-[16px]" /> Lihat Feedback
|
||||
<Button variant="secondary" size="sm">
|
||||
<MessageSquare className="size-3.5" />
|
||||
Lihat Feedback
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: sessions,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
state: { pagination, rowSelection },
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -121,64 +140,85 @@ function FeedbackReviewDimentorinPage(): ReactElement {
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||
manualPagination: true,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="Dimentorin.dev">
|
||||
<div className="mb-8 flex justify-between items-center">
|
||||
<h1 className="text-p1 font-semibold text-neutral-700">Feedback</h1>
|
||||
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
||||
<For data={Object.values(TABS)}>
|
||||
{(tab) => (
|
||||
<Button
|
||||
key={tab}
|
||||
variant="text"
|
||||
className={cn('px-3 py-2 capitalize', activeTab === tab && 'bg-white')}
|
||||
onClick={() => {
|
||||
setActiveTab(tab)
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }))
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
</Button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
<div className="flex justify-between items-center gap-5 mb-2">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama mentor/mentee"
|
||||
className="pl-12 w-full max-h-full"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||
<SearchOutlined />
|
||||
<BackofficeWrapper
|
||||
title="Feedback & Review"
|
||||
description="Review feedback dari mentoring & platform"
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => {
|
||||
setActiveTab(v as 'mentoring' | 'platform');
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="mentoring">Mentoring</TabsTrigger>
|
||||
<TabsTrigger value="platform">Platform</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<div className="relative w-full sm:w-72">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Cari nama mentor/mentee…"
|
||||
/>
|
||||
</div>
|
||||
<Select value={ratingFilter} onValueChange={setRatingFilter}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue placeholder="Rating" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Rating</SelectItem>
|
||||
<SelectItem value="4.5">4.5</SelectItem>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Status</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Select>
|
||||
<option disabled>Rating</option>
|
||||
<option value="4.5">4.5</option>
|
||||
<option value="5">5</option>
|
||||
</Select>
|
||||
<Select>
|
||||
<option disabled>Status</option>
|
||||
<option value="done">Done</option>
|
||||
<option value="todo">To Do</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
||||
) : activeTab === TABS.PLATFORM ? (
|
||||
<div className="text-center py-8 text-neutral-400">
|
||||
Platform feedback tidak tersedia
|
||||
</div>
|
||||
) : (
|
||||
<DataTable data={sessions} columns={columns} table={table} />
|
||||
)}
|
||||
</section>
|
||||
<TabsContent value="mentoring" className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
Memuat data…
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={sessions}
|
||||
columns={columns}
|
||||
table={table}
|
||||
manualPagination
|
||||
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||
currentPage={pagination.pageIndex + 1}
|
||||
onPageChange={(p) =>
|
||||
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="platform" className="mt-4">
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
Platform feedback belum tersedia
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</BackofficeWrapper>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import * as React from 'react'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
||||
import {
|
||||
SearchOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Input,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import {
|
||||
DataTable,
|
||||
BackofficeWrapper,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
@@ -16,143 +19,101 @@ import {
|
||||
PaginationState,
|
||||
useReactTable,
|
||||
RowSelectionState,
|
||||
} from '@tanstack/react-table'
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
useGachaItemList,
|
||||
useDeleteGachaItem,
|
||||
TGachaItemDto,
|
||||
} from '@imphnen-frontend-service/service'
|
||||
import { toast } from 'sonner'
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
SelectAllCheckbox,
|
||||
RowSelectCheckbox,
|
||||
DeleteConfirmDialog,
|
||||
} from '../../components/list-helpers';
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/gacha-roll')({
|
||||
component: GachaRollPage,
|
||||
})
|
||||
});
|
||||
|
||||
function GachaRollPage() {
|
||||
const navigate = useNavigate()
|
||||
const [search, setSearch] = useState('')
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
})
|
||||
|
||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
||||
pageSize: 10,
|
||||
});
|
||||
const [rowSelection, setRowSelection] =
|
||||
React.useState<RowSelectionState>({});
|
||||
|
||||
const { data: itemsData, isLoading } = useGachaItemList({
|
||||
search,
|
||||
page: pagination.pageIndex + 1,
|
||||
per_page: pagination.pageSize,
|
||||
})
|
||||
const deleteItem = useDeleteGachaItem()
|
||||
});
|
||||
const deleteItem = useDeleteGachaItem();
|
||||
|
||||
const items: TGachaItemDto[] = itemsData?.data ?? []
|
||||
const totalItems = itemsData?.meta?.total ?? items.length
|
||||
const items: TGachaItemDto[] = itemsData?.data ?? [];
|
||||
const totalItems = itemsData?.meta?.total ?? items.length;
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteItem.mutateAsync(id)
|
||||
toast.success('Item berhasil dihapus')
|
||||
setDeleteId(null)
|
||||
await deleteItem.mutateAsync(id);
|
||||
toast.success('Item berhasil dihapus');
|
||||
setDeleteId(null);
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
toast.error('Item gagal dihapus')
|
||||
console.log(error);
|
||||
toast.error('Item gagal dihapus');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<TGachaItemDto>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'No',
|
||||
accessorKey: 'id',
|
||||
},
|
||||
{
|
||||
header: 'Nama Item',
|
||||
accessorKey: 'name',
|
||||
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||
},
|
||||
{ header: 'No', accessorKey: 'id' },
|
||||
{ header: 'Nama Item', accessorKey: 'name' },
|
||||
{
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-[8px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate({ to: '/gacha-roll/$id', params: { id: row.original.id } })
|
||||
e.stopPropagation();
|
||||
navigate({
|
||||
to: '/gacha-roll/$id',
|
||||
params: { id: row.original.id },
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<EditOutlined /> Update
|
||||
<Pencil className="size-3.5" />
|
||||
Update
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteId(row.original.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
{deleteId === row.original.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-label2 text-neutral-500">Yakin?</span>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(row.original.id)
|
||||
}}
|
||||
>
|
||||
Ya
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteId(null)
|
||||
}}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteId(row.original.id)
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<DeleteOutlined /> Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: items,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
state: { pagination, rowSelection },
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -160,48 +121,61 @@ function GachaRollPage() {
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||
manualPagination: true,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">Gacha Roll</h1>
|
||||
</header>
|
||||
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
<div className="flex justify-between items-center gap-8 mb-2">
|
||||
<div className="relative w-full">
|
||||
<BackofficeWrapper
|
||||
title="Gacha Roll"
|
||||
description="Kelola item hadiah gacha"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama item"
|
||||
className="pl-12 w-full max-h-full"
|
||||
placeholder="Cari nama item…"
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||
<SearchOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex gap-3 text-nowrap"
|
||||
onClick={() => navigate({ to: '/gacha-roll/create' })}
|
||||
>
|
||||
<PlusOutlined />
|
||||
Tambah Item
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate({ to: '/gacha-roll/create' })}
|
||||
size="md"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Tambah Item
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
Memuat data…
|
||||
</div>
|
||||
) : (
|
||||
<DataTable data={items} columns={columns} table={table} />
|
||||
<DataTable
|
||||
data={items}
|
||||
columns={columns}
|
||||
table={table}
|
||||
manualPagination
|
||||
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||
currentPage={pagination.pageIndex + 1}
|
||||
onPageChange={(p) =>
|
||||
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</Fragment>
|
||||
)
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={!!deleteId}
|
||||
onOpenChange={(o) => !o && setDeleteId(null)}
|
||||
onConfirm={() => deleteId && handleDelete(deleteId)}
|
||||
title="Hapus item gacha ini?"
|
||||
/>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +1,82 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms'
|
||||
import { FC, ReactElement } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import { UsersRound, UserCog, ClipboardCheck } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||
import {
|
||||
getAdminUsers,
|
||||
getAdminTeams,
|
||||
getAdminSubmissions,
|
||||
} from '@imphnen-frontend-service/service'
|
||||
} from '@imphnen-frontend-service/service';
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/hackathon-dashboard')({
|
||||
component: HackathonDashboardPage,
|
||||
})
|
||||
});
|
||||
|
||||
type StatCardProps = {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
};
|
||||
|
||||
function StatCard({ icon: Icon, label, value }: StatCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-4 pt-6">
|
||||
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary-100 text-primary-600">
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-2xl font-semibold leading-tight text-foreground">
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function HackathonDashboardPage() {
|
||||
const { data: usersData } = useQuery({
|
||||
queryKey: ['admin-users-count'],
|
||||
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
|
||||
})
|
||||
|
||||
});
|
||||
const { data: teamsData } = useQuery({
|
||||
queryKey: ['admin-teams-count'],
|
||||
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
|
||||
})
|
||||
|
||||
});
|
||||
const { data: submissionsData } = useQuery({
|
||||
queryKey: ['admin-submissions-count'],
|
||||
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
|
||||
})
|
||||
});
|
||||
|
||||
const totalParticipants = usersData?.meta?.total_data ?? '??'
|
||||
const totalTeams = teamsData?.meta?.total_data ?? '??'
|
||||
const totalSubmissions = submissionsData?.meta?.total_data ?? '??'
|
||||
const totalParticipants = usersData?.meta?.total_data ?? '—';
|
||||
const totalTeams = teamsData?.meta?.total_data ?? '—';
|
||||
const totalSubmissions = submissionsData?.meta?.total_data ?? '—';
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
|
||||
|
||||
<section className="grid grid-cols-5 gap-5">
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||
{totalParticipants}
|
||||
</h3>
|
||||
<p className="text-neutral-400 text-p3">Total Participants</p>
|
||||
</div>
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||
{totalTeams}
|
||||
</h3>
|
||||
<p className="text-neutral-400 text-p3">Total Teams</p>
|
||||
</div>
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||
{totalSubmissions}
|
||||
</h3>
|
||||
<p className="text-neutral-400 text-p3">Total Project Submitted</p>
|
||||
</div>
|
||||
<BackofficeWrapper
|
||||
title="Hackathon Dashboard"
|
||||
description="IMPHNEN x Kolosal.ai Hackathon 2025"
|
||||
>
|
||||
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatCard
|
||||
icon={UserCog}
|
||||
label="Total Participants"
|
||||
value={totalParticipants}
|
||||
/>
|
||||
<StatCard icon={UsersRound} label="Total Teams" value={totalTeams} />
|
||||
<StatCard
|
||||
icon={ClipboardCheck}
|
||||
label="Total Project Submitted"
|
||||
value={totalSubmissions}
|
||||
/>
|
||||
</section>
|
||||
</BackofficeWrapper>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,27 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import {
|
||||
FC,
|
||||
ReactElement,
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import SubmissionModal from './_components/hackathon-submissions/submission-modal'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import { Search, Eye } from 'lucide-react';
|
||||
import SubmissionModal from './_components/hackathon-submissions/submission-modal';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms'
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { cn } from '@imphnen-frontend-service/utils'
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import {
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
LoadingOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Input,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
getAdminSubmissions,
|
||||
TAdminSubmissionItem,
|
||||
} from '@imphnen-frontend-service/service'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
} from '@imphnen-frontend-service/service';
|
||||
|
||||
type SubmissionType = TAdminSubmissionItem
|
||||
type SubmissionType = TAdminSubmissionItem;
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/hackathon-submissions')({
|
||||
component: HackathonSubmissionsPage,
|
||||
@@ -38,20 +31,20 @@ export const Route = createFileRoute('/_authenticated/hackathon-submissions')({
|
||||
per_page: Number(search.per_page) || 10,
|
||||
status: (search.status as string) || 'all',
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
function HackathonSubmissionsPage() {
|
||||
const searchParams = Route.useSearch()
|
||||
const navigate = useNavigate()
|
||||
const currentPage = Math.max(1, searchParams.page)
|
||||
const searchQuery = searchParams.search || ''
|
||||
const perPage = searchParams.per_page || 10
|
||||
const statusFilter = searchParams.status || 'all'
|
||||
const searchParams = Route.useSearch();
|
||||
const navigate = useNavigate();
|
||||
const currentPage = Math.max(1, searchParams.page);
|
||||
const searchQuery = searchParams.search || '';
|
||||
const perPage = searchParams.per_page || 10;
|
||||
const statusFilter = searchParams.status || 'all';
|
||||
|
||||
const [showSubmissionModal, setShowSubmissionModal] = useState(false)
|
||||
const [showSubmissionModal, setShowSubmissionModal] = React.useState(false);
|
||||
const [selectedSubmission, setSelectedSubmission] =
|
||||
useState<SubmissionType | null>(null)
|
||||
const [globalFilter, setGlobalFilter] = useState(searchQuery)
|
||||
React.useState<SubmissionType | null>(null);
|
||||
const [globalFilter, setGlobalFilter] = React.useState(searchQuery);
|
||||
|
||||
const {
|
||||
data: submissionsResponse,
|
||||
@@ -74,12 +67,12 @@ function HackathonSubmissionsPage() {
|
||||
}),
|
||||
staleTime: 30000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
})
|
||||
});
|
||||
|
||||
const totalData = submissionsResponse?.meta?.total_data || 0
|
||||
const totalPages = submissionsResponse?.meta?.total_page || 1
|
||||
const totalData = submissionsResponse?.meta?.total_data || 0;
|
||||
const totalPages = submissionsResponse?.meta?.total_page || 1;
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
const handlePageChange = React.useCallback(
|
||||
(newPage: number) => {
|
||||
navigate({
|
||||
search: {
|
||||
@@ -88,23 +81,23 @@ function HackathonSubmissionsPage() {
|
||||
search: searchQuery || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
} as any,
|
||||
})
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
[navigate, perPage, searchQuery, statusFilter]
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||
navigate({ search: { page: totalPages } as any })
|
||||
navigate({ search: { page: totalPages } as any });
|
||||
}
|
||||
}, [currentPage, totalPages, navigate, isLoading])
|
||||
}, [currentPage, totalPages, navigate, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
setGlobalFilter(searchQuery)
|
||||
}, [searchQuery])
|
||||
React.useEffect(() => {
|
||||
setGlobalFilter(searchQuery);
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
const handleSearch = React.useCallback(() => {
|
||||
navigate({
|
||||
search: {
|
||||
page: 1,
|
||||
@@ -112,56 +105,29 @@ function HackathonSubmissionsPage() {
|
||||
search: globalFilter.trim() || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
} as any,
|
||||
})
|
||||
}, [globalFilter, navigate, perPage, statusFilter])
|
||||
});
|
||||
}, [globalFilter, navigate, perPage, statusFilter]);
|
||||
|
||||
const handleSearchKeyPress = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch()
|
||||
}
|
||||
},
|
||||
[handleSearch]
|
||||
)
|
||||
const filteredData = React.useMemo<SubmissionType[]>(() => {
|
||||
return (
|
||||
((submissionsResponse?.data as any)?.data as SubmissionType[]) ??
|
||||
(submissionsResponse?.data as SubmissionType[]) ??
|
||||
[]
|
||||
);
|
||||
}, [submissionsResponse]);
|
||||
|
||||
const handlePerPageChange = useCallback(
|
||||
(newPerPage: number) => {
|
||||
navigate({
|
||||
search: {
|
||||
page: 1,
|
||||
per_page: newPerPage,
|
||||
search: searchQuery || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
} as any,
|
||||
})
|
||||
},
|
||||
[navigate, searchQuery, statusFilter]
|
||||
)
|
||||
const statusVariants: Record<string, 'success' | 'warning' | 'secondary'> = {
|
||||
submitted: 'success',
|
||||
pending: 'warning',
|
||||
};
|
||||
|
||||
const handleShowSubmissionModal = useCallback(
|
||||
(submission: SubmissionType) => {
|
||||
setSelectedSubmission(submission)
|
||||
setShowSubmissionModal(true)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleCloseSubmissionModal = useCallback(() => {
|
||||
setShowSubmissionModal(false)
|
||||
setSelectedSubmission(null)
|
||||
}, [])
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return submissionsResponse?.data?.data || submissionsResponse?.data || []
|
||||
}, [submissionsResponse])
|
||||
|
||||
const columns: ColumnDef<SubmissionType>[] = useMemo(
|
||||
const columns: ColumnDef<SubmissionType>[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'project_name',
|
||||
header: 'Project Name',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium text-neutral-900">
|
||||
<span className="font-medium text-foreground">
|
||||
{row.original.project_name}
|
||||
</span>
|
||||
),
|
||||
@@ -171,7 +137,7 @@ function HackathonSubmissionsPage() {
|
||||
accessorKey: 'team_id',
|
||||
header: 'Team ID',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-neutral-700 font-mono">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{row.original.team_id}
|
||||
</span>
|
||||
),
|
||||
@@ -180,30 +146,21 @@ function HackathonSubmissionsPage() {
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
|
||||
status === 'submitted'
|
||||
? 'bg-success-100 text-success-800'
|
||||
: status === 'pending'
|
||||
? 'bg-orange-100 text-orange-800'
|
||||
: 'bg-neutral-100 text-neutral-700'
|
||||
)}
|
||||
>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={statusVariants[row.original.status] ?? 'secondary'}
|
||||
className="capitalize"
|
||||
>
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'submitted_at',
|
||||
header: 'Submitted',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-neutral-900 text-sm">
|
||||
<span className="text-sm text-foreground">
|
||||
{new Date(row.original.submitted_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
@@ -217,109 +174,87 @@ function HackathonSubmissionsPage() {
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
meta: { cellClassName: cn('w-48') },
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 text-sm px-4 py-2"
|
||||
onClick={() => handleShowSubmissionModal(row.original)}
|
||||
onClick={() => {
|
||||
setSelectedSubmission(row.original);
|
||||
setShowSubmissionModal(true);
|
||||
}}
|
||||
>
|
||||
<EyeOutlined className="text-sm" />
|
||||
<Eye className="size-3.5" />
|
||||
View
|
||||
</Button>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[handleShowSubmissionModal]
|
||||
)
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||
Project Submissions
|
||||
</h1>
|
||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="relative">
|
||||
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||
<input
|
||||
type="text"
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="Search by project name..."
|
||||
<BackofficeWrapper
|
||||
title="Project Submissions"
|
||||
description="IMPHNEN x Kolosal.ai Hackathon 2025"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Cari nama project…"
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
onKeyPress={handleSearchKeyPress}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value={perPage}
|
||||
onChange={(e) =>
|
||||
handlePerPageChange(parseInt(e.target.value, 10))
|
||||
}
|
||||
>
|
||||
<option value={10}>10 / page</option>
|
||||
<option value={20}>20 / page</option>
|
||||
<option value={50}>50 / page</option>
|
||||
<option value={100}>100 / page</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
Memuat submissions…
|
||||
</div>
|
||||
|
||||
{}
|
||||
{
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
{
|
||||
}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
||||
<span className="ml-3 text-neutral-600">
|
||||
Loading submissions...
|
||||
</span>
|
||||
</div>
|
||||
) : filteredData.length > 0 ? (
|
||||
<>
|
||||
<div className="text-sm text-neutral-600">
|
||||
Showing {filteredData.length} of {totalData} submissions (Page{' '}
|
||||
{currentPage} of {totalPages})
|
||||
{isFetching && (
|
||||
<span className="ml-2 text-primary-500">(Updating...)</span>
|
||||
)}
|
||||
) : filteredData.length > 0 ? (
|
||||
<>
|
||||
<div className="mb-3 text-xs text-muted-foreground">
|
||||
Menampilkan {filteredData.length} dari {totalData} submissions
|
||||
(page {currentPage} / {totalPages})
|
||||
{isFetching && (
|
||||
<span className="ml-2 text-primary-500">Updating…</span>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={filteredData}
|
||||
columns={columns}
|
||||
pageSize={perPage}
|
||||
manualPagination
|
||||
pageCount={totalPages}
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
Tidak ada submissions.
|
||||
</div>
|
||||
<DataTable
|
||||
data={filteredData}
|
||||
columns={columns}
|
||||
pageSize={perPage}
|
||||
manualPagination={true}
|
||||
pageCount={totalPages}
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-neutral-500">
|
||||
No submissions found. Try adjusting your filters.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedSubmission && (
|
||||
<SubmissionModal
|
||||
isOpen={showSubmissionModal}
|
||||
onClose={handleCloseSubmissionModal}
|
||||
onClose={() => {
|
||||
setShowSubmissionModal(false);
|
||||
setSelectedSubmission(null);
|
||||
}}
|
||||
submission={selectedSubmission}
|
||||
/>
|
||||
)}
|
||||
</BackofficeWrapper>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,30 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import {
|
||||
FC,
|
||||
ReactElement,
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import ModalTeamDetail from './_components/hackathon-teams/modal-team-detail-new'
|
||||
import { CityFilterSelect } from '../../components/city-filter-select'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import { Search, Plus, Users as TeamIcon, Pencil, X } from 'lucide-react';
|
||||
import ModalTeamDetail from './_components/hackathon-teams/modal-team-detail-new';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms'
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { cn } from '@imphnen-frontend-service/utils'
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import {
|
||||
EditOutlined,
|
||||
TeamOutlined,
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
PlusOutlined,
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Input,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
getAdminTeams,
|
||||
TAdminTeamItem,
|
||||
} from '@imphnen-frontend-service/service'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
} from '@imphnen-frontend-service/service';
|
||||
|
||||
type TeamType = TAdminTeamItem
|
||||
type TeamType = TAdminTeamItem;
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/hackathon-teams')({
|
||||
component: HackathonTeamsPage,
|
||||
@@ -40,22 +33,21 @@ export const Route = createFileRoute('/_authenticated/hackathon-teams')({
|
||||
search: (search.search as string) || '',
|
||||
per_page: Number(search.per_page) || 10,
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
function HackathonTeamsPage() {
|
||||
const searchParams = Route.useSearch()
|
||||
const navigate = useNavigate()
|
||||
const currentPage = Math.max(1, searchParams.page)
|
||||
const searchQuery = searchParams.search || ''
|
||||
const perPage = searchParams.per_page || 10
|
||||
const [showDetailModal, setShowDetailModal] = useState(false)
|
||||
const [showNewTeamModal, setShowNewTeamModal] = useState(false)
|
||||
const [selectedTeam, setSelectedTeam] = useState<TeamType | null>(null)
|
||||
useState<TeamType | null>(null)
|
||||
const [globalFilter, setGlobalFilter] = useState(searchQuery)
|
||||
const searchParams = Route.useSearch();
|
||||
const navigate = useNavigate();
|
||||
const currentPage = Math.max(1, searchParams.page);
|
||||
const searchQuery = searchParams.search || '';
|
||||
const perPage = searchParams.per_page || 10;
|
||||
|
||||
const [visibilityFilter, setVisibilityFilter] = useState('all')
|
||||
const [cityFilter, setCityFilter] = useState('all')
|
||||
const [showDetailModal, setShowDetailModal] = React.useState(false);
|
||||
const [showNewTeamModal, setShowNewTeamModal] = React.useState(false);
|
||||
const [selectedTeam, setSelectedTeam] = React.useState<TeamType | null>(null);
|
||||
const [globalFilter, setGlobalFilter] = React.useState(searchQuery);
|
||||
const [visibilityFilter, setVisibilityFilter] = React.useState('all');
|
||||
const [cityFilter, setCityFilter] = React.useState('all');
|
||||
|
||||
const {
|
||||
data: teamsResponse,
|
||||
@@ -78,12 +70,12 @@ function HackathonTeamsPage() {
|
||||
}),
|
||||
staleTime: 30000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
})
|
||||
});
|
||||
|
||||
const totalData = teamsResponse?.meta?.total_data || 0
|
||||
const totalPages = teamsResponse?.meta?.total_page || 1
|
||||
const totalData = teamsResponse?.meta?.total_data || 0;
|
||||
const totalPages = teamsResponse?.meta?.total_page || 1;
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
const handlePageChange = React.useCallback(
|
||||
(newPage: number) => {
|
||||
navigate({
|
||||
search: {
|
||||
@@ -91,142 +83,97 @@ function HackathonTeamsPage() {
|
||||
per_page: perPage !== 10 ? perPage : undefined,
|
||||
search: searchQuery || undefined,
|
||||
} as any,
|
||||
})
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
[navigate, perPage, searchQuery]
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||
navigate({ search: { page: totalPages } as any })
|
||||
navigate({ search: { page: totalPages } as any });
|
||||
}
|
||||
}, [currentPage, totalPages, navigate, isLoading])
|
||||
}, [currentPage, totalPages, navigate, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
setGlobalFilter(searchQuery)
|
||||
}, [searchQuery])
|
||||
React.useEffect(() => {
|
||||
setGlobalFilter(searchQuery);
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
const handleSearch = React.useCallback(() => {
|
||||
navigate({
|
||||
search: {
|
||||
page: 1,
|
||||
per_page: perPage !== 10 ? perPage : undefined,
|
||||
search: globalFilter.trim() || undefined,
|
||||
} as any,
|
||||
})
|
||||
}, [globalFilter, navigate, perPage])
|
||||
});
|
||||
}, [globalFilter, navigate, perPage]);
|
||||
|
||||
const handleSearchKeyPress = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch()
|
||||
}
|
||||
},
|
||||
[handleSearch]
|
||||
)
|
||||
const filteredData = React.useMemo<TeamType[]>(() => {
|
||||
return (
|
||||
((teamsResponse?.data as any)?.data as TeamType[]) ??
|
||||
(teamsResponse?.data as TeamType[]) ??
|
||||
[]
|
||||
);
|
||||
}, [teamsResponse]);
|
||||
|
||||
const handlePerPageChange = useCallback(
|
||||
(newPerPage: number) => {
|
||||
navigate({
|
||||
search: {
|
||||
page: 1,
|
||||
per_page: newPerPage,
|
||||
search: searchQuery || undefined,
|
||||
} as any,
|
||||
})
|
||||
},
|
||||
[navigate, searchQuery]
|
||||
)
|
||||
const handleShowDetailModal = React.useCallback((team: TeamType) => {
|
||||
setSelectedTeam(team);
|
||||
setShowDetailModal(true);
|
||||
}, []);
|
||||
|
||||
const handleShowDetailModal = useCallback((team: TeamType) => {
|
||||
setSelectedTeam(team)
|
||||
setShowDetailModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseDetailModal = useCallback(() => {
|
||||
setShowDetailModal(false)
|
||||
setSelectedTeam(null)
|
||||
}, [])
|
||||
|
||||
const handleShowNewTeamModal = useCallback(() => {
|
||||
setShowNewTeamModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseNewTeamModal = useCallback(() => {
|
||||
setShowNewTeamModal(false)
|
||||
}, [])
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return teamsResponse?.data?.data || teamsResponse?.data || []
|
||||
}, [teamsResponse])
|
||||
|
||||
const columns: ColumnDef<TeamType>[] = useMemo(
|
||||
const columns: ColumnDef<TeamType>[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Team',
|
||||
cell: ({ row }) => {
|
||||
const team = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-neutral-100 flex items-center justify-center shrink-0 overflow-hidden">
|
||||
{team.logo ? (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<TeamOutlined className="text-neutral-400 text-lg" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className="font-medium text-neutral-900 truncate max-w-sm"
|
||||
title={team.name}
|
||||
>
|
||||
{team.name}
|
||||
</p>
|
||||
</div>
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarImage src={row.original.logo ?? undefined} alt={row.original.name} />
|
||||
<AvatarFallback>
|
||||
<TeamIcon className="size-4 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className="truncate font-medium text-foreground"
|
||||
title={row.original.name}
|
||||
>
|
||||
{row.original.name}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
</div>
|
||||
),
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'city',
|
||||
header: 'City',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-neutral-700">{row.original.city}</span>
|
||||
<span className="text-foreground">{row.original.city}</span>
|
||||
),
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'visibility',
|
||||
header: 'Visibility',
|
||||
cell: ({ row }) => {
|
||||
const isPublic = row.original.visibility === 'public'
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
|
||||
isPublic
|
||||
? 'bg-success-100 text-success-800'
|
||||
: 'bg-neutral-100 text-neutral-700'
|
||||
)}
|
||||
>
|
||||
{isPublic ? 'Public' : 'Private'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.visibility === 'public' ? 'success' : 'secondary'
|
||||
}
|
||||
>
|
||||
{row.original.visibility === 'public' ? 'Public' : 'Private'}
|
||||
</Badge>
|
||||
),
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: 'leader',
|
||||
header: 'Leader ID',
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm text-neutral-700 font-mono">
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
{row.original.leader_id}
|
||||
</div>
|
||||
),
|
||||
@@ -236,7 +183,7 @@ function HackathonTeamsPage() {
|
||||
accessorKey: 'created_at',
|
||||
header: 'Created',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-neutral-900 text-sm">
|
||||
<span className="text-sm text-foreground">
|
||||
{new Date(row.original.created_at).toLocaleDateString('en-UK', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
@@ -250,168 +197,127 @@ function HackathonTeamsPage() {
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
meta: { cellClassName: cn('w-48') },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 text-sm px-4 py-2"
|
||||
onClick={() => handleShowDetailModal(row.original)}
|
||||
>
|
||||
<EditOutlined className="text-sm" />
|
||||
Manage
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleShowDetailModal(row.original)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
Manage
|
||||
</Button>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[handleShowDetailModal]
|
||||
)
|
||||
);
|
||||
|
||||
const hasActiveFilters = visibilityFilter !== 'all' || cityFilter !== 'all';
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||
Team Management
|
||||
</h1>
|
||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="relative">
|
||||
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||
<input
|
||||
type="text"
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="Search teams by name or city..."
|
||||
<BackofficeWrapper
|
||||
title="Hackathon Teams"
|
||||
description="IMPHNEN x Kolosal.ai Hackathon 2025"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Cari nama atau kota…"
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
onKeyPress={handleSearchKeyPress}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value={perPage}
|
||||
onChange={(e) =>
|
||||
handlePerPageChange(parseInt(e.target.value, 10))
|
||||
}
|
||||
>
|
||||
<option value={10}>10 / page</option>
|
||||
<option value={20}>20 / page</option>
|
||||
<option value={50}>50 / page</option>
|
||||
<option value={100}>100 / page</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{}
|
||||
{
|
||||
}
|
||||
|
||||
{}
|
||||
{
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex items-center gap-2 px-4 py-2"
|
||||
onClick={handleShowNewTeamModal}
|
||||
>
|
||||
<PlusOutlined className="text-sm" />
|
||||
<Button onClick={() => setShowNewTeamModal(true)} size="md">
|
||||
<Plus className="size-4" />
|
||||
Add Team
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(visibilityFilter !== 'all' || cityFilter !== 'all') && (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||
|
||||
{visibilityFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||
Visibility: {visibilityFilter}
|
||||
<button
|
||||
onClick={() => setVisibilityFilter('all')}
|
||||
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{hasActiveFilters && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Active filters:
|
||||
</span>
|
||||
)}
|
||||
|
||||
{cityFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||
City: {cityFilter}
|
||||
<button
|
||||
onClick={() => setCityFilter('all')}
|
||||
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setVisibilityFilter('all')
|
||||
setCityFilter('all')
|
||||
setGlobalFilter('')
|
||||
}}
|
||||
className="text-sm text-neutral-600"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
||||
<span className="ml-3 text-neutral-600">Loading teams...</span>
|
||||
</div>
|
||||
) : filteredData.length > 0 ? (
|
||||
<>
|
||||
<div className="text-sm text-neutral-600">
|
||||
Showing {filteredData.length} of {totalData} teams (Page{' '}
|
||||
{currentPage} of {totalPages})
|
||||
{isFetching && (
|
||||
<span className="ml-2 text-primary-500">(Updating...)</span>
|
||||
{visibilityFilter !== 'all' && (
|
||||
<Badge variant="info" className="gap-1">
|
||||
Visibility: {visibilityFilter}
|
||||
<button onClick={() => setVisibilityFilter('all')}>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
{cityFilter !== 'all' && (
|
||||
<Badge variant="success" className="gap-1">
|
||||
City: {cityFilter}
|
||||
<button onClick={() => setCityFilter('all')}>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setVisibilityFilter('all');
|
||||
setCityFilter('all');
|
||||
setGlobalFilter('');
|
||||
}}
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
data={filteredData}
|
||||
columns={columns}
|
||||
pageSize={perPage}
|
||||
manualPagination={true}
|
||||
pageCount={totalPages}
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-neutral-500">
|
||||
No teams found. Try adjusting your filters.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
Memuat data teams…
|
||||
</div>
|
||||
) : filteredData.length > 0 ? (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Menampilkan {filteredData.length} dari {totalData} teams (page{' '}
|
||||
{currentPage} / {totalPages})
|
||||
{isFetching && (
|
||||
<span className="ml-2 text-primary-500">Updating…</span>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={filteredData}
|
||||
columns={columns}
|
||||
pageSize={perPage}
|
||||
manualPagination
|
||||
pageCount={totalPages}
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
Tidak ada team. Coba ubah filter pencarian.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ModalTeamDetail
|
||||
isOpen={showDetailModal}
|
||||
onClose={handleCloseDetailModal}
|
||||
onClose={() => {
|
||||
setShowDetailModal(false);
|
||||
setSelectedTeam(null);
|
||||
}}
|
||||
team={selectedTeam}
|
||||
/>
|
||||
|
||||
<ModalTeamDetail
|
||||
isOpen={showNewTeamModal}
|
||||
onClose={handleCloseNewTeamModal}
|
||||
onClose={() => setShowNewTeamModal(false)}
|
||||
team={null}
|
||||
/>
|
||||
</BackofficeWrapper>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,48 +1,31 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import {
|
||||
FC,
|
||||
ReactElement,
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import ModalUserDetail from './_components/hackathon-users/modal-user-detail'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import { Search, Plus, User, Pencil, X } from 'lucide-react';
|
||||
import ModalUserDetail from './_components/hackathon-users/modal-user-detail';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms'
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { cn } from '@imphnen-frontend-service/utils'
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import {
|
||||
EditOutlined,
|
||||
UserOutlined,
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
PlusOutlined,
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { CityFilterSelect } from '../../components/city-filter-select'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Input,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
getAdminUsers,
|
||||
TAdminUserItem,
|
||||
} from '@imphnen-frontend-service/service'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
} from '@imphnen-frontend-service/service';
|
||||
|
||||
type UserType = TAdminUserItem
|
||||
|
||||
const skillsOptions = [
|
||||
'Frontend Developer',
|
||||
'Backend Developer',
|
||||
'Full Stack Developer',
|
||||
'DevOps Engineer',
|
||||
'UI/UX Designer',
|
||||
'Product Manager',
|
||||
'Data Scientist',
|
||||
'Mobile Developer',
|
||||
]
|
||||
type UserType = TAdminUserItem;
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/hackathon-users')({
|
||||
component: HackathonUsersPage,
|
||||
@@ -51,36 +34,28 @@ export const Route = createFileRoute('/_authenticated/hackathon-users')({
|
||||
search: (search.search as string) || '',
|
||||
per_page: Number(search.per_page) || 10,
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
function HackathonUsersPage() {
|
||||
const searchParams = Route.useSearch()
|
||||
const navigate = useNavigate()
|
||||
const currentPage = Math.max(1, searchParams.page)
|
||||
const searchQuery = searchParams.search || ''
|
||||
const perPage = searchParams.per_page || 10
|
||||
const [showDetailModal, setShowDetailModal] = useState(false)
|
||||
const [showNewUserModal, setShowNewUserModal] = useState(false)
|
||||
const [selectedUser, setSelectedUser] = useState<UserType | null>(null)
|
||||
const [globalFilter, setGlobalFilter] = useState(searchQuery)
|
||||
const searchParams = Route.useSearch();
|
||||
const navigate = useNavigate();
|
||||
const currentPage = Math.max(1, searchParams.page);
|
||||
const searchQuery = searchParams.search || '';
|
||||
const perPage = searchParams.per_page || 10;
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [cityFilter, setCityFilter] = useState('all')
|
||||
const [skillsFilter, setSkillsFilter] = useState<string[]>([])
|
||||
const [showDetailModal, setShowDetailModal] = React.useState(false);
|
||||
const [showNewUserModal, setShowNewUserModal] = React.useState(false);
|
||||
const [selectedUser, setSelectedUser] = React.useState<UserType | null>(null);
|
||||
const [globalFilter, setGlobalFilter] = React.useState(searchQuery);
|
||||
const [statusFilter, setStatusFilter] = React.useState('all');
|
||||
const [skillsFilter, setSkillsFilter] = React.useState<string[]>([]);
|
||||
|
||||
const {
|
||||
data: usersResponse,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
'admin-users',
|
||||
currentPage,
|
||||
perPage,
|
||||
cityFilter,
|
||||
statusFilter,
|
||||
searchQuery,
|
||||
],
|
||||
queryKey: ['admin-users', currentPage, perPage, statusFilter, searchQuery],
|
||||
queryFn: () =>
|
||||
getAdminUsers({
|
||||
page: currentPage,
|
||||
@@ -89,12 +64,12 @@ function HackathonUsersPage() {
|
||||
}),
|
||||
staleTime: 30000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
})
|
||||
});
|
||||
|
||||
const totalData = usersResponse?.meta?.total_data || 0
|
||||
const totalPages = usersResponse?.meta?.total_page || 1
|
||||
const totalData = usersResponse?.meta?.total_data || 0;
|
||||
const totalPages = usersResponse?.meta?.total_page || 1;
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
const handlePageChange = React.useCallback(
|
||||
(newPage: number) => {
|
||||
navigate({
|
||||
search: {
|
||||
@@ -102,112 +77,73 @@ function HackathonUsersPage() {
|
||||
per_page: perPage !== 10 ? perPage : undefined,
|
||||
search: searchQuery || undefined,
|
||||
} as any,
|
||||
})
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
[navigate, perPage, searchQuery]
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||
navigate({ search: { page: totalPages } as any })
|
||||
navigate({ search: { page: totalPages } as any });
|
||||
}
|
||||
}, [currentPage, totalPages, navigate, isLoading])
|
||||
}, [currentPage, totalPages, navigate, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
setGlobalFilter(searchQuery)
|
||||
}, [searchQuery])
|
||||
React.useEffect(() => {
|
||||
setGlobalFilter(searchQuery);
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
const handleSearch = React.useCallback(() => {
|
||||
navigate({
|
||||
search: {
|
||||
page: 1,
|
||||
per_page: perPage !== 10 ? perPage : undefined,
|
||||
search: globalFilter.trim() || undefined,
|
||||
} as any,
|
||||
})
|
||||
}, [globalFilter, navigate, perPage])
|
||||
});
|
||||
}, [globalFilter, navigate, perPage]);
|
||||
|
||||
const handleSearchKeyPress = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch()
|
||||
}
|
||||
},
|
||||
[handleSearch]
|
||||
)
|
||||
const handleShowDetailModal = React.useCallback((user: UserType) => {
|
||||
setSelectedUser(user);
|
||||
setShowDetailModal(true);
|
||||
}, []);
|
||||
|
||||
const handlePerPageChange = useCallback(
|
||||
(newPerPage: number) => {
|
||||
navigate({
|
||||
search: {
|
||||
page: 1,
|
||||
per_page: newPerPage,
|
||||
search: searchQuery || undefined,
|
||||
} as any,
|
||||
})
|
||||
},
|
||||
[navigate, searchQuery]
|
||||
)
|
||||
|
||||
const handleShowDetailModal = useCallback((user: UserType) => {
|
||||
setSelectedUser(user)
|
||||
setShowDetailModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseDetailModal = useCallback(() => {
|
||||
setShowDetailModal(false)
|
||||
setSelectedUser(null)
|
||||
}, [])
|
||||
|
||||
const handleShowNewUserModal = useCallback(() => {
|
||||
setShowNewUserModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseNewUserModal = useCallback(() => {
|
||||
setShowNewUserModal(false)
|
||||
}, [])
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const usersData = usersResponse?.data?.data || usersResponse?.data || []
|
||||
return usersData.filter((user: UserType) => {
|
||||
const filteredData = React.useMemo(() => {
|
||||
const usersData: UserType[] =
|
||||
((usersResponse?.data as any)?.data as UserType[]) ??
|
||||
(usersResponse?.data as UserType[]) ??
|
||||
[];
|
||||
return usersData.filter((user) => {
|
||||
if (statusFilter !== 'all') {
|
||||
const isActive = statusFilter === 'active'
|
||||
if (user.is_active !== isActive) return false
|
||||
const isActive = statusFilter === 'active';
|
||||
if (user.is_active !== isActive) return false;
|
||||
}
|
||||
|
||||
if (skillsFilter.length > 0) {
|
||||
const userSkills = user.skills || []
|
||||
const userSkills = user.skills || [];
|
||||
const hasMatchingSkill = skillsFilter.some((skill) =>
|
||||
userSkills.includes(skill)
|
||||
)
|
||||
if (!hasMatchingSkill) return false
|
||||
);
|
||||
if (!hasMatchingSkill) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [usersResponse, statusFilter, skillsFilter]);
|
||||
|
||||
return true
|
||||
})
|
||||
}, [usersResponse, statusFilter, skillsFilter])
|
||||
|
||||
const columns: ColumnDef<UserType>[] = useMemo(
|
||||
const columns: ColumnDef<UserType>[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'fullname',
|
||||
header: 'User',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
|
||||
{row.original.avatar ? (
|
||||
<img
|
||||
src={row.original.avatar}
|
||||
alt={row.original.fullname}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<UserOutlined className="text-neutral-500 text-lg" />
|
||||
)}
|
||||
</div>
|
||||
<Avatar className="size-9">
|
||||
<AvatarImage src={row.original.avatar ?? undefined} alt={row.original.fullname} />
|
||||
<AvatarFallback>
|
||||
<User className="size-4 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-neutral-900 truncate">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{row.original.fullname}
|
||||
</p>
|
||||
</div>
|
||||
@@ -219,30 +155,22 @@ function HackathonUsersPage() {
|
||||
accessorKey: 'skills',
|
||||
header: 'Skills',
|
||||
cell: ({ row }) => {
|
||||
const skills = row.original.skills || []
|
||||
const skills = row.original.skills || [];
|
||||
if (skills.length === 0) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||
{skills.length > 0 ? (
|
||||
<>
|
||||
{skills.slice(0, 2).map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
|
||||
>
|
||||
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||
</span>
|
||||
))}
|
||||
{skills.length > 2 && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700">
|
||||
+{skills.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-neutral-400">-</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{skills.slice(0, 2).map((skill, index) => (
|
||||
<Badge key={index} variant="success">
|
||||
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||
</Badge>
|
||||
))}
|
||||
{skills.length > 2 && (
|
||||
<Badge variant="secondary">+{skills.length - 2}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
@@ -250,7 +178,7 @@ function HackathonUsersPage() {
|
||||
accessorKey: 'location',
|
||||
header: 'Location',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-neutral-700">{row.original.location}</span>
|
||||
<span className="text-foreground">{row.original.location}</span>
|
||||
),
|
||||
enableSorting: true,
|
||||
},
|
||||
@@ -259,16 +187,16 @@ function HackathonUsersPage() {
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
<span
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
'size-2 rounded-full',
|
||||
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
row.original.is_active ? 'text-success-700' : 'text-neutral-500'
|
||||
row.original.is_active ? 'text-success-700' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||
@@ -277,18 +205,18 @@ function HackathonUsersPage() {
|
||||
),
|
||||
enableSorting: true,
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const aActive = rowA.original.is_active
|
||||
const bActive = rowB.original.is_active
|
||||
if (aActive && !bActive) return -1
|
||||
if (!aActive && bActive) return 1
|
||||
return 0
|
||||
const a = rowA.original.is_active;
|
||||
const b = rowB.original.is_active;
|
||||
if (a && !b) return -1;
|
||||
if (!a && b) return 1;
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Joined',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-neutral-900 text-sm">
|
||||
<span className="text-sm text-foreground">
|
||||
{new Date(row.original.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
@@ -302,194 +230,135 @@ function HackathonUsersPage() {
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
meta: { cellClassName: cn('w-48') },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 text-sm px-4 py-2"
|
||||
onClick={() => handleShowDetailModal(row.original)}
|
||||
>
|
||||
<EditOutlined className="text-sm" />
|
||||
Manage
|
||||
</Button>
|
||||
{
|
||||
}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleShowDetailModal(row.original)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
Manage
|
||||
</Button>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[handleShowDetailModal]
|
||||
)
|
||||
);
|
||||
|
||||
const hasActiveFilters = statusFilter !== 'all' || skillsFilter.length > 0;
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||
User Management
|
||||
</h1>
|
||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="relative">
|
||||
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||
<input
|
||||
type="text"
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="Search users by name or location..."
|
||||
<BackofficeWrapper
|
||||
title="Hackathon Users"
|
||||
description="IMPHNEN x Kolosal.ai Hackathon 2025"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Cari nama atau lokasi…"
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
onKeyPress={handleSearchKeyPress}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value={perPage}
|
||||
onChange={(e) =>
|
||||
handlePerPageChange(parseInt(e.target.value, 10))
|
||||
}
|
||||
>
|
||||
<option value={10}>10 / page</option>
|
||||
<option value={20}>20 / page</option>
|
||||
<option value={50}>50 / page</option>
|
||||
<option value={100}>100 / page</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{}
|
||||
{
|
||||
}
|
||||
|
||||
{}
|
||||
{
|
||||
}
|
||||
|
||||
{}
|
||||
{
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex items-center gap-2 px-4 py-2"
|
||||
onClick={handleShowNewUserModal}
|
||||
>
|
||||
<PlusOutlined className="text-sm" />
|
||||
<Button onClick={() => setShowNewUserModal(true)} size="md">
|
||||
<Plus className="size-4" />
|
||||
Add User
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(skillsFilter.length > 0 ||
|
||||
statusFilter !== 'all' ||
|
||||
cityFilter !== 'all') && (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||
|
||||
{statusFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||
Status: {statusFilter}
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{hasActiveFilters && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Active filters:
|
||||
</span>
|
||||
)}
|
||||
|
||||
{cityFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||
City: {cityFilter}
|
||||
<button
|
||||
onClick={() => setCityFilter('all')}
|
||||
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{skillsFilter.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
|
||||
>
|
||||
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||
<button
|
||||
onClick={() =>
|
||||
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
|
||||
}
|
||||
className="text-purple-600 hover:text-purple-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStatusFilter('all')
|
||||
setCityFilter('all')
|
||||
setSkillsFilter([])
|
||||
setGlobalFilter('')
|
||||
}}
|
||||
className="text-sm text-neutral-600"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
||||
<span className="ml-3 text-neutral-600">Loading users...</span>
|
||||
</div>
|
||||
) : filteredData.length > 0 ? (
|
||||
<>
|
||||
<div className="text-sm text-neutral-600">
|
||||
Showing {filteredData.length} of {totalData} users (Page{' '}
|
||||
{currentPage} of {totalPages})
|
||||
{isFetching && (
|
||||
<span className="ml-2 text-primary-500">(Updating...)</span>
|
||||
{statusFilter !== 'all' && (
|
||||
<Badge variant="info" className="gap-1">
|
||||
Status: {statusFilter}
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
aria-label="Clear status filter"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
{skillsFilter.map((skill) => (
|
||||
<Badge key={skill} variant="secondary" className="gap-1">
|
||||
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||
<button
|
||||
onClick={() =>
|
||||
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
|
||||
}
|
||||
aria-label={`Clear ${skill} filter`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStatusFilter('all');
|
||||
setSkillsFilter([]);
|
||||
setGlobalFilter('');
|
||||
}}
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
data={filteredData}
|
||||
columns={columns}
|
||||
pageSize={perPage}
|
||||
manualPagination={true}
|
||||
pageCount={totalPages}
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-neutral-500">
|
||||
No users found. Try adjusting your filters.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<span>Memuat data users…</span>
|
||||
</div>
|
||||
) : filteredData.length > 0 ? (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Menampilkan {filteredData.length} dari {totalData} users (page{' '}
|
||||
{currentPage} / {totalPages})
|
||||
{isFetching && (
|
||||
<span className="ml-2 text-primary-500">Updating…</span>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={filteredData}
|
||||
columns={columns}
|
||||
pageSize={perPage}
|
||||
manualPagination
|
||||
pageCount={totalPages}
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
Tidak ada user. Coba ubah filter pencarian.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ModalUserDetail
|
||||
isOpen={showDetailModal}
|
||||
onClose={handleCloseDetailModal}
|
||||
onClose={() => {
|
||||
setShowDetailModal(false);
|
||||
setSelectedUser(null);
|
||||
}}
|
||||
user={selectedUser}
|
||||
/>
|
||||
|
||||
<ModalUserDetail
|
||||
isOpen={showNewUserModal}
|
||||
onClose={handleCloseNewUserModal}
|
||||
onClose={() => setShowNewUserModal(false)}
|
||||
user={null}
|
||||
/>
|
||||
</BackofficeWrapper>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
||||
import {
|
||||
SearchOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Input,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import {
|
||||
DataTable,
|
||||
BackofficeWrapper,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
@@ -15,144 +19,101 @@ import {
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
usePermissionList,
|
||||
useDeletePermission,
|
||||
TPermissionItem,
|
||||
} from '@imphnen-frontend-service/service'
|
||||
import React from 'react'
|
||||
import { toast } from 'sonner'
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
SelectAllCheckbox,
|
||||
RowSelectCheckbox,
|
||||
DeleteConfirmDialog,
|
||||
} from '../../components/list-helpers';
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/permissions')({
|
||||
component: PermissionsPage,
|
||||
})
|
||||
});
|
||||
|
||||
function PermissionsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [search, setSearch] = useState('')
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
})
|
||||
|
||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
||||
pageSize: 10,
|
||||
});
|
||||
const [rowSelection, setRowSelection] =
|
||||
React.useState<RowSelectionState>({});
|
||||
|
||||
const { data: permissionsData, isLoading } = usePermissionList({
|
||||
search,
|
||||
page: pagination.pageIndex + 1,
|
||||
per_page: pagination.pageSize,
|
||||
})
|
||||
const deletePermission = useDeletePermission()
|
||||
});
|
||||
const deletePermission = useDeletePermission();
|
||||
|
||||
const permissions: TPermissionItem[] = permissionsData?.data ?? []
|
||||
const totalItems = permissionsData?.meta?.total ?? permissions.length
|
||||
const permissions: TPermissionItem[] = permissionsData?.data ?? [];
|
||||
const totalItems = permissionsData?.meta?.total ?? permissions.length;
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deletePermission.mutateAsync(id)
|
||||
toast.success('Data permissions berhasil dihapus')
|
||||
setDeleteId(null)
|
||||
await deletePermission.mutateAsync(id);
|
||||
toast.success('Data permission berhasil dihapus');
|
||||
setDeleteId(null);
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
toast.error('Data permissions gagal dihapus')
|
||||
console.log(error);
|
||||
toast.error('Data permission gagal dihapus');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<TPermissionItem>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'No',
|
||||
accessorKey: 'id',
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||
},
|
||||
{ header: 'No', accessorKey: 'id' },
|
||||
{ header: 'Name', accessorKey: 'name' },
|
||||
{
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-[8px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate({ to: '/permissions/$id', params: { id: row.original.id } })
|
||||
e.stopPropagation();
|
||||
navigate({
|
||||
to: '/permissions/$id',
|
||||
params: { id: row.original.id },
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<EditOutlined /> Update
|
||||
<Pencil className="size-3.5" />
|
||||
Update
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteId(row.original.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
{deleteId === row.original.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-label2 text-neutral-500">Yakin?</span>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(row.original.id)
|
||||
}}
|
||||
>
|
||||
Ya
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteId(null)
|
||||
}}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteId(row.original.id)
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<DeleteOutlined /> Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: permissions,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
state: { pagination, rowSelection },
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -160,53 +121,61 @@ function PermissionsPage() {
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||
manualPagination: true,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">Permissions</h1>
|
||||
</header>
|
||||
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
<div className="flex justify-between items-center gap-8 mb-2">
|
||||
<div className="relative w-full">
|
||||
<BackofficeWrapper
|
||||
title="Permissions"
|
||||
description="Kelola hak akses sistem"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama permissions"
|
||||
className="pl-12 w-full max-h-full"
|
||||
placeholder="Cari nama permission…"
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||
<SearchOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex gap-3 text-nowrap"
|
||||
onClick={() => navigate({ to: '/permissions/create' })}
|
||||
>
|
||||
<PlusOutlined />
|
||||
Tambah Permissions
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate({ to: '/permissions/create' })}
|
||||
size="md"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Tambah Permission
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
Memuat data…
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={permissions}
|
||||
columns={columns}
|
||||
pageSize={9}
|
||||
table={table}
|
||||
manualPagination
|
||||
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||
currentPage={pagination.pageIndex + 1}
|
||||
onPageChange={(p) =>
|
||||
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</Fragment>
|
||||
)
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={!!deleteId}
|
||||
onOpenChange={(o) => !o && setDeleteId(null)}
|
||||
onConfirm={() => deleteId && handleDelete(deleteId)}
|
||||
title="Hapus permission ini?"
|
||||
/>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import * as React from 'react'
|
||||
import { FC, Fragment, ReactElement, useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import { Filter as FilterIcon, Search, ClipboardCheck } from 'lucide-react';
|
||||
import {
|
||||
FilterOutlined,
|
||||
SearchOutlined,
|
||||
AuditOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms'
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import {
|
||||
DataTable,
|
||||
Filter,
|
||||
BackofficeWrapper,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
@@ -15,19 +24,23 @@ import {
|
||||
PaginationState,
|
||||
useReactTable,
|
||||
RowSelectionState,
|
||||
} from '@tanstack/react-table'
|
||||
import ModalProcessDelivery from './_components/prizes/modal-process-item'
|
||||
} from '@tanstack/react-table';
|
||||
import ModalProcessDelivery from './_components/prizes/modal-process-item';
|
||||
import {
|
||||
SelectAllCheckbox,
|
||||
RowSelectCheckbox,
|
||||
} from '../../components/list-helpers';
|
||||
|
||||
type OrderValid = 'valid' | 'invalid' | 'unchecked'
|
||||
type Status = 'undelivered' | 'delivered'
|
||||
type OrderValid = 'valid' | 'invalid' | 'unchecked';
|
||||
type Status = 'undelivered' | 'delivered';
|
||||
|
||||
interface Prize {
|
||||
id: number
|
||||
name: string
|
||||
orderValid: OrderValid
|
||||
items: string
|
||||
address: string
|
||||
status: Status
|
||||
id: number;
|
||||
name: string;
|
||||
orderValid: OrderValid;
|
||||
items: string;
|
||||
address: string;
|
||||
status: Status;
|
||||
}
|
||||
|
||||
const items = [
|
||||
@@ -37,7 +50,7 @@ const items = [
|
||||
'Sticker Isi 3',
|
||||
'Sticker Isi 5',
|
||||
'Gelang Karet',
|
||||
]
|
||||
];
|
||||
|
||||
const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
@@ -45,141 +58,108 @@ const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
orderValid: (i % 3 === 0
|
||||
? 'invalid'
|
||||
: i % 5 === 0
|
||||
? 'unchecked'
|
||||
: 'valid') as OrderValid,
|
||||
? 'unchecked'
|
||||
: 'valid') as OrderValid,
|
||||
items: items[i % items.length],
|
||||
address: 'Jl. Pantai Cibaduyut Indah',
|
||||
status: (i % 3 === 0 ? 'undelivered' : 'delivered') as Status,
|
||||
}))
|
||||
}));
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/prizes')({
|
||||
component: PrizesPage,
|
||||
})
|
||||
});
|
||||
|
||||
function PrizesPage() {
|
||||
const [showModalProcessDelivery, setShowModalProcessDelivery] =
|
||||
useState(false)
|
||||
|
||||
React.useState(false);
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
})
|
||||
|
||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
||||
const [showFilter, setShowFilter] = useState(false)
|
||||
pageSize: 10,
|
||||
});
|
||||
const [rowSelection, setRowSelection] =
|
||||
React.useState<RowSelectionState>({});
|
||||
const [showFilter, setShowFilter] = React.useState(false);
|
||||
|
||||
const deliveryOptions = [
|
||||
{ id: 'option1', value: 'undelivered', label: 'Undelivered' },
|
||||
{ id: 'option1', value: 'delivered', label: 'Delivered' },
|
||||
]
|
||||
{ id: 'undelivered', value: 'undelivered', label: 'Undelivered' },
|
||||
{ id: 'delivered', value: 'delivered', label: 'Delivered' },
|
||||
];
|
||||
|
||||
const orderValidVariants: Record<
|
||||
OrderValid,
|
||||
'success' | 'destructive' | 'warning'
|
||||
> = {
|
||||
valid: 'success',
|
||||
invalid: 'destructive',
|
||||
unchecked: 'warning',
|
||||
};
|
||||
|
||||
const orderValidText: Record<OrderValid, string> = {
|
||||
valid: 'Valid',
|
||||
invalid: 'Invalid',
|
||||
unchecked: 'Unchecked',
|
||||
};
|
||||
|
||||
const statusVariants: Record<Status, 'success' | 'destructive'> = {
|
||||
delivered: 'success',
|
||||
undelivered: 'destructive',
|
||||
};
|
||||
|
||||
const statusText: Record<Status, string> = {
|
||||
delivered: 'Delivered',
|
||||
undelivered: 'Undelivered',
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Prize>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'No',
|
||||
accessorKey: 'id',
|
||||
},
|
||||
{
|
||||
header: 'Nama Lengkap',
|
||||
accessorKey: 'name',
|
||||
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||
},
|
||||
{ header: 'No', accessorKey: 'id' },
|
||||
{ header: 'Nama Lengkap', accessorKey: 'name' },
|
||||
{
|
||||
header: 'Order Valid?',
|
||||
accessorKey: 'orderValid',
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.orderValid
|
||||
const statusColors: Record<OrderValid, string> = {
|
||||
valid: 'bg-success-200 text-success-500',
|
||||
invalid: 'bg-danger-200 text-danger-500',
|
||||
unchecked: 'bg-warning-200 text-warning-900',
|
||||
}
|
||||
const statusText: Record<OrderValid, string> = {
|
||||
valid: 'Valid',
|
||||
invalid: 'Invalid',
|
||||
unchecked: 'Unchecked',
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||
>
|
||||
{statusText[status]}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Items',
|
||||
accessorKey: 'items',
|
||||
},
|
||||
{
|
||||
header: 'Alamat Pengiriman',
|
||||
accessorKey: 'address',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={orderValidVariants[row.original.orderValid]}>
|
||||
{orderValidText[row.original.orderValid]}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ header: 'Items', accessorKey: 'items' },
|
||||
{ header: 'Alamat Pengiriman', accessorKey: 'address' },
|
||||
{
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status
|
||||
const statusColors: Record<Status, string> = {
|
||||
delivered: 'bg-success-200 text-success-500',
|
||||
undelivered: 'bg-danger-200 text-danger-500',
|
||||
}
|
||||
const statusText: Record<Status, string> = {
|
||||
delivered: 'Delivered',
|
||||
undelivered: 'Undelivered',
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||
>
|
||||
{statusText[status]}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={statusVariants[row.original.status]}>
|
||||
{statusText[row.original.status]}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
cell: () => (
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowModalProcessDelivery(true)
|
||||
e.stopPropagation();
|
||||
setShowModalProcessDelivery(true);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full"
|
||||
>
|
||||
<AuditOutlined className="text-[16px]" /> Process
|
||||
<ClipboardCheck className="size-3.5" />
|
||||
Process
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockData,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
state: { pagination, rowSelection },
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -187,59 +167,55 @@ function PrizesPage() {
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||
manualPagination: false,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">Data Pengiriman Hadiah</h1>
|
||||
</header>
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
<div className="flex justify-between items-center gap-8 mb-2">
|
||||
<div className="relative w-full">
|
||||
<BackofficeWrapper
|
||||
title="Data Pengiriman Hadiah"
|
||||
description="Proses pengiriman hadiah ke pemenang"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama lengkap, nomor order Shopee"
|
||||
className="pl-12 w-full max-h-full"
|
||||
className="pl-9"
|
||||
placeholder="Cari nama atau nomor order…"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||
<SearchOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex items-center gap-3"
|
||||
onClick={() => setShowFilter(!showFilter)}
|
||||
>
|
||||
<FilterOutlined />
|
||||
Filters
|
||||
</Button>
|
||||
{showFilter && (
|
||||
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
|
||||
<Filter
|
||||
options={deliveryOptions}
|
||||
onClose={() => setShowFilter(false)}
|
||||
onFilterChange={(value) => {
|
||||
console.log('Selected filter:', value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Popover open={showFilter} onOpenChange={setShowFilter}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="secondary" size="md">
|
||||
<FilterIcon className="size-4" />
|
||||
Filters
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-auto p-0">
|
||||
<Filter
|
||||
options={deliveryOptions}
|
||||
title="Status"
|
||||
onClose={() => setShowFilter(false)}
|
||||
onFilterChange={(value) => {
|
||||
console.log('Selected filter:', value);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable data={mockData} columns={columns} table={table} />
|
||||
</section>
|
||||
</main>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ModalProcessDelivery
|
||||
isOpen={showModalProcessDelivery}
|
||||
onClose={() => setShowModalProcessDelivery(false)}
|
||||
handleProcessDelivery={() => {
|
||||
console.log('Action ketika user menekan tombol Proses Pengiriman')
|
||||
console.log('Action proses pengiriman');
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,182 +1,162 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { BackofficeWrapper, DataTable } from '@imphnen-frontend-service/ui/organisms'
|
||||
import { cn } from '@imphnen-frontend-service/utils'
|
||||
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from '@tanstack/react-table'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useRoadmapList, useDeleteRoadmap, TRoadmapListItem, TRoadmapStatus } from '@imphnen-frontend-service/service'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import { Search, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
useRoadmapList,
|
||||
useDeleteRoadmap,
|
||||
TRoadmapListItem,
|
||||
TRoadmapStatus,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import {
|
||||
SelectAllCheckbox,
|
||||
RowSelectCheckbox,
|
||||
DeleteConfirmDialog,
|
||||
} from '../../components/list-helpers';
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/roadmap-dimentorin')({
|
||||
component: RoadmapDimentorinPage,
|
||||
})
|
||||
});
|
||||
|
||||
function RoadmapDimentorinPage(): React.ReactElement {
|
||||
const navigate = useNavigate()
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
function RoadmapDimentorinPage() {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [statusFilter, setStatusFilter] = React.useState('all');
|
||||
const [deletingId, setDeletingId] = React.useState<string | null>(null);
|
||||
const [rowSelection, setRowSelection] =
|
||||
React.useState<RowSelectionState>({});
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
})
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const { data: roadmapData, isLoading } = useRoadmapList()
|
||||
const deleteRoadmap = useDeleteRoadmap()
|
||||
const { data: roadmapData, isLoading } = useRoadmapList();
|
||||
const deleteRoadmap = useDeleteRoadmap();
|
||||
|
||||
const allItems: TRoadmapListItem[] = roadmapData ?? []
|
||||
const allItems: TRoadmapListItem[] = roadmapData ?? [];
|
||||
const filteredItems = allItems.filter((item) => {
|
||||
const matchSearch = !search || item.title.toLowerCase().includes(search.toLowerCase())
|
||||
const matchStatus = !statusFilter || item.status === statusFilter
|
||||
return matchSearch && matchStatus
|
||||
})
|
||||
const matchSearch =
|
||||
!search || item.title.toLowerCase().includes(search.toLowerCase());
|
||||
const matchStatus = statusFilter === 'all' || item.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteRoadmap.mutateAsync(id)
|
||||
toast.success('Roadmap berhasil dihapus')
|
||||
setDeletingId(null)
|
||||
await deleteRoadmap.mutateAsync(id);
|
||||
toast.success('Roadmap berhasil dihapus');
|
||||
setDeletingId(null);
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
toast.error('Gagal menghapus roadmap')
|
||||
console.log(error);
|
||||
toast.error('Gagal menghapus roadmap');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const statusColors: Record<TRoadmapStatus, string> = {
|
||||
upcoming: 'bg-warning-200 text-warning-700',
|
||||
in_progress: 'bg-primary-200 text-primary-700',
|
||||
completed: 'bg-success-200 text-success-500',
|
||||
}
|
||||
const statusVariants: Record<
|
||||
TRoadmapStatus,
|
||||
'warning' | 'info' | 'success'
|
||||
> = {
|
||||
upcoming: 'warning',
|
||||
in_progress: 'info',
|
||||
completed: 'success',
|
||||
};
|
||||
|
||||
const statusText: Record<TRoadmapStatus, string> = {
|
||||
upcoming: 'Upcoming',
|
||||
in_progress: 'In Progress',
|
||||
completed: 'Completed',
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<TRoadmapListItem>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
meta: { cellClassName: cn('w-20') },
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'title',
|
||||
header: 'Title',
|
||||
accessorKey: 'title',
|
||||
meta: { cellClassName: cn('w-10') },
|
||||
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||
},
|
||||
{ id: 'title', header: 'Title', accessorKey: 'title' },
|
||||
{
|
||||
id: 'description',
|
||||
header: 'Description',
|
||||
accessorKey: 'description',
|
||||
cell: ({ row }) => (
|
||||
<span className="line-clamp-2">{row.original.description}</span>
|
||||
<span className="line-clamp-2 max-w-md">{row.original.description}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status
|
||||
return (
|
||||
<div className={`py-2 px-4 rounded-md text-center ${statusColors[status] ?? 'bg-neutral-200 text-neutral-700'}`}>
|
||||
{statusText[status] ?? status}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'votes',
|
||||
header: 'Votes',
|
||||
accessorKey: 'votes',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={statusVariants[row.original.status] ?? 'secondary'}>
|
||||
{statusText[row.original.status] ?? row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ id: 'votes', header: 'Votes', accessorKey: 'votes' },
|
||||
{
|
||||
header: 'Action',
|
||||
meta: { cellClassName: cn('w-72') },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-[8px]">
|
||||
{deletingId === row.original.id ? (
|
||||
<>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(row.original.id)
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Konfirmasi
|
||||
</Button>
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeletingId(null)
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate({ to: '/roadmap-dimentorin/$id', params: { id: row.original.id } })
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<EditOutlined /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeletingId(row.original.id)
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<DeleteOutlined /> Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate({
|
||||
to: '/roadmap-dimentorin/$id',
|
||||
params: { id: row.original.id },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeletingId(row.original.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredItems,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
state: { pagination, rowSelection },
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -184,51 +164,64 @@ function RoadmapDimentorinPage(): React.ReactElement {
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(filteredItems.length / pagination.pageSize),
|
||||
manualPagination: false,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="Dimentorin.dev">
|
||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Content & Roadmap</h1>
|
||||
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
<div className="flex items-center justify-between mb-9">
|
||||
<h2 className="text-p2 font-semibold text-neutral-600">AI Roadmaps</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate({ to: '/roadmap-dimentorin/create' })}
|
||||
>
|
||||
<PlusOutlined /> Buat Roadmap
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center gap-5 mb-2">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
placeholder="Cari berdasarkan judul roadmap"
|
||||
className="pl-12 w-full max-h-full"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||
<SearchOutlined />
|
||||
<BackofficeWrapper
|
||||
title="Content & Roadmap"
|
||||
description="Kelola AI roadmap dimentorin"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-1 flex-col gap-2 sm:flex-row">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Cari judul roadmap…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Status</SelectItem>
|
||||
<SelectItem value="upcoming">Upcoming</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
size="md"
|
||||
onClick={() => navigate({ to: '/roadmap-dimentorin/create' })}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Buat Roadmap
|
||||
</Button>
|
||||
</div>
|
||||
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="">Semua Status</option>
|
||||
<option value="upcoming">Upcoming</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="completed">Completed</option>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
Memuat data…
|
||||
</div>
|
||||
) : (
|
||||
<DataTable data={filteredItems} columns={columns} table={table} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
||||
) : (
|
||||
<DataTable data={filteredItems} columns={columns} table={table} />
|
||||
)}
|
||||
</section>
|
||||
<DeleteConfirmDialog
|
||||
open={!!deletingId}
|
||||
onOpenChange={(o) => !o && setDeletingId(null)}
|
||||
onConfirm={() => deletingId && handleDelete(deletingId)}
|
||||
title="Hapus roadmap ini?"
|
||||
/>
|
||||
</BackofficeWrapper>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
||||
import {
|
||||
SearchOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Input,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import {
|
||||
DataTable,
|
||||
BackofficeWrapper,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
@@ -15,144 +19,98 @@ import {
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
useRoleList,
|
||||
useDeleteRole,
|
||||
TRolesListItem,
|
||||
} from '@imphnen-frontend-service/service'
|
||||
import React from 'react'
|
||||
import { toast } from 'sonner'
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
SelectAllCheckbox,
|
||||
RowSelectCheckbox,
|
||||
DeleteConfirmDialog,
|
||||
} from '../../components/list-helpers';
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/roles')({
|
||||
component: RolesPage,
|
||||
})
|
||||
});
|
||||
|
||||
function RolesPage() {
|
||||
const navigate = useNavigate()
|
||||
const [search, setSearch] = useState('')
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
})
|
||||
|
||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
||||
pageSize: 10,
|
||||
});
|
||||
const [rowSelection, setRowSelection] =
|
||||
React.useState<RowSelectionState>({});
|
||||
|
||||
const { data: rolesData, isLoading } = useRoleList({
|
||||
search,
|
||||
page: pagination.pageIndex + 1,
|
||||
per_page: pagination.pageSize,
|
||||
})
|
||||
const deleteRole = useDeleteRole()
|
||||
});
|
||||
const deleteRole = useDeleteRole();
|
||||
|
||||
const roles: TRolesListItem[] = rolesData?.data ?? []
|
||||
const totalItems = rolesData?.meta?.total ?? roles.length
|
||||
const roles: TRolesListItem[] = rolesData?.data ?? [];
|
||||
const totalItems = rolesData?.meta?.total ?? roles.length;
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteRole.mutateAsync(id)
|
||||
toast.success('Data role berhasil dihapus')
|
||||
setDeleteId(null)
|
||||
await deleteRole.mutateAsync(id);
|
||||
toast.success('Data role berhasil dihapus');
|
||||
setDeleteId(null);
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
toast.error('Data role gagal dihapus')
|
||||
console.log(error);
|
||||
toast.error('Data role gagal dihapus');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<TRolesListItem>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'ID',
|
||||
accessorKey: 'id',
|
||||
},
|
||||
{
|
||||
header: 'Roles Name',
|
||||
accessorKey: 'name',
|
||||
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||
},
|
||||
{ header: 'ID', accessorKey: 'id' },
|
||||
{ header: 'Roles Name', accessorKey: 'name' },
|
||||
{
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-[8px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate({ to: '/roles/$id', params: { id: row.original.id } })
|
||||
e.stopPropagation();
|
||||
navigate({ to: '/roles/$id', params: { id: row.original.id } });
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<EditOutlined /> Update
|
||||
<Pencil className="size-3.5" />
|
||||
Update
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteId(row.original.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
{deleteId === row.original.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-label2 text-neutral-500">Yakin?</span>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(row.original.id)
|
||||
}}
|
||||
>
|
||||
Ya
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteId(null)
|
||||
}}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteId(row.original.id)
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<DeleteOutlined /> Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: roles,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
state: { pagination, rowSelection },
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -160,53 +118,58 @@ function RolesPage() {
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||
manualPagination: true,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">Roles</h1>
|
||||
</header>
|
||||
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
<div className="flex justify-between items-center gap-8 mb-2">
|
||||
<div className="relative w-full">
|
||||
<BackofficeWrapper title="Roles" description="Kelola role dan akses">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama roles"
|
||||
className="pl-12 w-full max-h-full"
|
||||
placeholder="Cari nama role…"
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||
<SearchOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex gap-3 text-nowrap"
|
||||
onClick={() => navigate({ to: '/roles/create' })}
|
||||
>
|
||||
<PlusOutlined />
|
||||
Tambah Role
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate({ to: '/roles/create' })}
|
||||
size="md"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Tambah Role
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
Memuat data…
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={roles}
|
||||
columns={columns}
|
||||
pageSize={9}
|
||||
table={table}
|
||||
manualPagination
|
||||
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||
currentPage={pagination.pageIndex + 1}
|
||||
onPageChange={(p) =>
|
||||
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</Fragment>
|
||||
)
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={!!deleteId}
|
||||
onOpenChange={(o) => !o && setDeleteId(null)}
|
||||
onConfirm={() => deleteId && handleDelete(deleteId)}
|
||||
title="Hapus role ini?"
|
||||
/>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +1,82 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { BackofficeWrapper, DataTable } from '@imphnen-frontend-service/ui/organisms'
|
||||
import { cn } from '@imphnen-frontend-service/utils'
|
||||
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from '@tanstack/react-table'
|
||||
import { ReactElement, useState } from 'react'
|
||||
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import { Search, Eye } from 'lucide-react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
useMySessions,
|
||||
TSessionListItem,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import {
|
||||
SelectAllCheckbox,
|
||||
RowSelectCheckbox,
|
||||
} from '../../components/list-helpers';
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/session-dimentorin')({
|
||||
component: SessionDimentorinPage,
|
||||
})
|
||||
});
|
||||
|
||||
function SessionDimentorinPage(): ReactElement {
|
||||
const navigate = useNavigate()
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
function SessionDimentorinPage() {
|
||||
const navigate = useNavigate();
|
||||
const [statusFilter, setStatusFilter] = React.useState('all');
|
||||
const [rowSelection, setRowSelection] =
|
||||
React.useState<RowSelectionState>({});
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
})
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const { data: sessionsData, isLoading } = useMySessions(
|
||||
statusFilter ? { status: statusFilter } : undefined
|
||||
)
|
||||
statusFilter !== 'all' ? { status: statusFilter } : undefined
|
||||
);
|
||||
|
||||
const sessions: TSessionListItem[] = sessionsData?.sessions ?? []
|
||||
const totalItems = sessionsData?.total ?? sessions.length
|
||||
const sessions: TSessionListItem[] = sessionsData?.sessions ?? [];
|
||||
const totalItems = sessionsData?.total ?? sessions.length;
|
||||
|
||||
const statusVariants: Record<
|
||||
string,
|
||||
'warning' | 'info' | 'success' | 'destructive' | 'secondary'
|
||||
> = {
|
||||
pending: 'warning',
|
||||
confirmed: 'info',
|
||||
ongoing: 'warning',
|
||||
completed: 'success',
|
||||
cancelled: 'destructive',
|
||||
};
|
||||
|
||||
const columns: ColumnDef<TSessionListItem>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
meta: { cellClassName: cn('w-20') },
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'id',
|
||||
header: 'ID Sesi',
|
||||
accessorKey: 'id',
|
||||
},
|
||||
{
|
||||
id: 'mentorId',
|
||||
header: 'Nama Mentor',
|
||||
accessorKey: 'mentor_id',
|
||||
meta: { cellClassName: cn('w-10') },
|
||||
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||
},
|
||||
{ id: 'id', header: 'ID Sesi', accessorKey: 'id' },
|
||||
{ id: 'mentorId', header: 'Nama Mentor', accessorKey: 'mentor_id' },
|
||||
{
|
||||
id: 'menteeName',
|
||||
header: 'Nama Mentee',
|
||||
@@ -69,55 +87,49 @@ function SessionDimentorinPage(): ReactElement {
|
||||
header: 'Waktu',
|
||||
accessorKey: 'scheduled_at',
|
||||
cell: ({ row }) => (
|
||||
<span>{new Date(row.original.scheduled_at).toLocaleString('id-ID')}</span>
|
||||
<span>
|
||||
{new Date(row.original.scheduled_at).toLocaleString('id-ID')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-warning-200 text-warning-700',
|
||||
confirmed: 'bg-primary-200 text-primary-700',
|
||||
ongoing: 'bg-warning-200 text-warning-700',
|
||||
completed: 'bg-success-200 text-success-500',
|
||||
cancelled: 'bg-danger-200 text-danger-500',
|
||||
}
|
||||
return (
|
||||
<div className={`py-2 px-4 rounded-md text-center capitalize ${statusColors[status] ?? 'bg-neutral-200 text-neutral-700'}`}>
|
||||
{status}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={statusVariants[row.original.status] ?? 'secondary'}
|
||||
className="capitalize"
|
||||
>
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
meta: { cellClassName: cn('w-52') },
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate({ to: '/session-dimentorin/$id', params: { id: row.original.id } })
|
||||
e.stopPropagation();
|
||||
navigate({
|
||||
to: '/session-dimentorin/$id',
|
||||
params: { id: row.original.id },
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-2 w-max"
|
||||
>
|
||||
<SearchOutlined className="text-[16px]" /> Cek Detail
|
||||
<Eye className="size-3.5" />
|
||||
Detail
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: sessions,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
state: { pagination, rowSelection },
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -125,39 +137,55 @@ function SessionDimentorinPage(): ReactElement {
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||
manualPagination: true,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="Dimentorin.dev">
|
||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Session Management</h1>
|
||||
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
<div className="flex justify-between items-center gap-5 mb-2">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama lengkap"
|
||||
className="pl-12 w-full max-h-full"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||
<SearchOutlined />
|
||||
<BackofficeWrapper
|
||||
title="Session Management"
|
||||
description="Kelola sesi mentoring aktif"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input className="pl-9" placeholder="Cari nama lengkap…" />
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Status</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="confirmed">Confirmed</SelectItem>
|
||||
<SelectItem value="ongoing">On Going</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="">Semua Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="ongoing">On Going</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
||||
) : (
|
||||
<DataTable data={sessions} columns={columns} table={table} />
|
||||
)}
|
||||
</section>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
Memuat data…
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={sessions}
|
||||
columns={columns}
|
||||
table={table}
|
||||
manualPagination
|
||||
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||
currentPage={pagination.pageIndex + 1}
|
||||
onPageChange={(p) =>
|
||||
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</BackofficeWrapper>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { Input, Select, Textarea } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { Input, NativeSelect as Select, Textarea } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { cn, For } from '@imphnen-frontend-service/utils'
|
||||
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service'
|
||||
|
||||
|
||||
@@ -1,58 +1,74 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms'
|
||||
import { cn, For } from '@imphnen-frontend-service/utils'
|
||||
import { useState } from 'react'
|
||||
import { GeneralSettings } from './_components/settings-dimentorin/general'
|
||||
import { UserRolesPermission } from './_components/settings-dimentorin/user-roles-permission'
|
||||
import { NotificationSettings } from './_components/settings-dimentorin/notification'
|
||||
import { SecuritySettings } from './_components/settings-dimentorin/security'
|
||||
import { PaymentSettings } from './_components/settings-dimentorin/payment'
|
||||
|
||||
const TABS = {
|
||||
general: 'General Settings',
|
||||
userRolePermissions: 'User Roles & Permissions',
|
||||
notification: 'Notification Settings',
|
||||
security: 'Security',
|
||||
payment: 'Payment',
|
||||
} as const
|
||||
type Tabs = typeof TABS[keyof typeof TABS]
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { GeneralSettings } from './_components/settings-dimentorin/general';
|
||||
import { UserRolesPermission } from './_components/settings-dimentorin/user-roles-permission';
|
||||
import { NotificationSettings } from './_components/settings-dimentorin/notification';
|
||||
import { SecuritySettings } from './_components/settings-dimentorin/security';
|
||||
import { PaymentSettings } from './_components/settings-dimentorin/payment';
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/settings-dimentorin')({
|
||||
component: SettingsDimentorinPage,
|
||||
})
|
||||
});
|
||||
|
||||
function SettingsDimentorinPage(): React.ReactElement {
|
||||
const [activeTab, setActiveTab] = useState<Tabs>(TABS.general)
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="Dimentorin.dev">
|
||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Settings</h1>
|
||||
|
||||
<div className="flex items-start gap-x-8">
|
||||
<div className="w-64 bg-white p-2.5 shadow space-y-2 rounded-md">
|
||||
<For data={Object.values(TABS)}>
|
||||
{(tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={cn(
|
||||
'px-4 py-3 w-full text-left font-medium rounded-md text-neutral-400 cursor-pointer select-none hover:bg-primary-100',
|
||||
activeTab === tab && 'bg-primary-500 text-white hover:bg-primary-600'
|
||||
)}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div className="bg-white px-8 py-6 shadow space-y-2 rounded-md flex-1">
|
||||
{activeTab === TABS.general && <GeneralSettings />}
|
||||
{activeTab === TABS.userRolePermissions && <UserRolesPermission />}
|
||||
{activeTab === TABS.notification && <NotificationSettings />}
|
||||
{activeTab === TABS.security && <SecuritySettings />}
|
||||
{activeTab === TABS.payment && <PaymentSettings />}
|
||||
</div>
|
||||
</div>
|
||||
<BackofficeWrapper
|
||||
title="Dimentorin Settings"
|
||||
description="Konfigurasi platform Dimentorin.dev"
|
||||
>
|
||||
<Tabs defaultValue="general" className="gap-6">
|
||||
<TabsList className="flex-wrap">
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="roles">Roles & Permissions</TabsTrigger>
|
||||
<TabsTrigger value="notification">Notification</TabsTrigger>
|
||||
<TabsTrigger value="security">Security</TabsTrigger>
|
||||
<TabsTrigger value="payment">Payment</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="general">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<GeneralSettings />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="roles">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<UserRolesPermission />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="notification">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<NotificationSettings />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="security">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<SecuritySettings />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="payment">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<PaymentSettings />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</BackofficeWrapper>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import * as React from 'react'
|
||||
import { FC, Fragment, ReactElement, useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import { Filter as FilterIcon, Search, ClipboardCheck } from 'lucide-react';
|
||||
import {
|
||||
FilterOutlined,
|
||||
SearchOutlined,
|
||||
AuditOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms'
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import {
|
||||
DataTable,
|
||||
Filter,
|
||||
BackofficeWrapper,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
@@ -15,131 +24,111 @@ import {
|
||||
PaginationState,
|
||||
useReactTable,
|
||||
RowSelectionState,
|
||||
} from '@tanstack/react-table'
|
||||
import ModalValidate from './_components/transactions/modal-validate'
|
||||
} from '@tanstack/react-table';
|
||||
import ModalValidate from './_components/transactions/modal-validate';
|
||||
import {
|
||||
SelectAllCheckbox,
|
||||
RowSelectCheckbox,
|
||||
} from '../../components/list-helpers';
|
||||
|
||||
type TransactionStatus = 'valid' | 'invalid' | 'unchecked'
|
||||
type TransactionStatus = 'valid' | 'invalid' | 'unchecked';
|
||||
|
||||
interface Transaction {
|
||||
id: number
|
||||
name: string
|
||||
transactionNumber: string
|
||||
status: TransactionStatus
|
||||
id: number;
|
||||
name: string;
|
||||
transactionNumber: string;
|
||||
status: TransactionStatus;
|
||||
}
|
||||
|
||||
const mockTransactions: Transaction[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
|
||||
transactionNumber: '25D2133Y9AFYBD',
|
||||
status: (i % 3 === 0
|
||||
? 'invalid'
|
||||
: i % 5 === 0
|
||||
? 'unchecked'
|
||||
: 'valid') as TransactionStatus,
|
||||
}))
|
||||
const mockTransactions: Transaction[] = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) => ({
|
||||
id: i + 1,
|
||||
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
|
||||
transactionNumber: '25D2133Y9AFYBD',
|
||||
status: (i % 3 === 0
|
||||
? 'invalid'
|
||||
: i % 5 === 0
|
||||
? 'unchecked'
|
||||
: 'valid') as TransactionStatus,
|
||||
})
|
||||
);
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/transactions')({
|
||||
component: TransactionsPage,
|
||||
})
|
||||
});
|
||||
|
||||
function TransactionsPage() {
|
||||
const [showModalValidate, setShowModalValidate] = useState(false)
|
||||
|
||||
const [showModalValidate, setShowModalValidate] = React.useState(false);
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
})
|
||||
|
||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
||||
const [showFilter, setShowFilter] = useState(false)
|
||||
pageSize: 10,
|
||||
});
|
||||
const [rowSelection, setRowSelection] =
|
||||
React.useState<RowSelectionState>({});
|
||||
const [showFilter, setShowFilter] = React.useState(false);
|
||||
|
||||
const validationOptions = [
|
||||
{ id: 'option1', value: 'unchecked', label: 'Unchecked' },
|
||||
{ id: 'option2', value: 'valid', label: 'Valid' },
|
||||
{ id: 'option3', value: 'invalid', label: 'Invalid' },
|
||||
]
|
||||
{ id: 'unchecked', value: 'unchecked', label: 'Unchecked' },
|
||||
{ id: 'valid', value: 'valid', label: 'Valid' },
|
||||
{ id: 'invalid', value: 'invalid', label: 'Invalid' },
|
||||
];
|
||||
|
||||
const statusVariants: Record<
|
||||
TransactionStatus,
|
||||
'success' | 'destructive' | 'warning'
|
||||
> = {
|
||||
valid: 'success',
|
||||
invalid: 'destructive',
|
||||
unchecked: 'warning',
|
||||
};
|
||||
|
||||
const statusText: Record<TransactionStatus, string> = {
|
||||
valid: 'Valid',
|
||||
invalid: 'Invalid',
|
||||
unchecked: 'Unchecked',
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Transaction>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'No',
|
||||
accessorKey: 'id',
|
||||
},
|
||||
{
|
||||
header: 'Nama Lengkap',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Nomor Transaksi',
|
||||
accessorKey: 'transactionNumber',
|
||||
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||
},
|
||||
{ header: 'No', accessorKey: 'id' },
|
||||
{ header: 'Nama Lengkap', accessorKey: 'name' },
|
||||
{ header: 'Nomor Transaksi', accessorKey: 'transactionNumber' },
|
||||
{
|
||||
header: 'Order Valid?',
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status
|
||||
const statusColors: Record<TransactionStatus, string> = {
|
||||
valid: 'bg-success-200 text-success-500',
|
||||
invalid: 'bg-danger-200 text-danger-500',
|
||||
unchecked: 'bg-warning-200 text-warning-900',
|
||||
}
|
||||
const statusText: Record<TransactionStatus, string> = {
|
||||
valid: 'Valid',
|
||||
invalid: 'Invalid',
|
||||
unchecked: 'Unchecked',
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||
>
|
||||
{statusText[status]}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={statusVariants[row.original.status]}>
|
||||
{statusText[row.original.status]}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
cell: () => (
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowModalValidate(true)
|
||||
e.stopPropagation();
|
||||
setShowModalValidate(true);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full"
|
||||
>
|
||||
<AuditOutlined className="text-[16px]" /> Update
|
||||
<ClipboardCheck className="size-3.5" />
|
||||
Update
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockTransactions,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
state: { pagination, rowSelection },
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -147,64 +136,58 @@ function TransactionsPage() {
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(mockTransactions.length / pagination.pageSize),
|
||||
manualPagination: false,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">Validasi Transaksi</h1>
|
||||
</header>
|
||||
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
<div className="flex justify-between items-center gap-8 mb-2">
|
||||
<div className="relative w-full">
|
||||
<BackofficeWrapper
|
||||
title="Validasi Transaksi"
|
||||
description="Verifikasi status transaksi pengguna"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama lengkap, nomor order Shopee"
|
||||
className="pl-12 w-full max-h-full"
|
||||
className="pl-9"
|
||||
placeholder="Cari nama atau nomor order…"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||
<SearchOutlined />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex items-center gap-3"
|
||||
onClick={() => setShowFilter(!showFilter)}
|
||||
>
|
||||
<FilterOutlined />
|
||||
Filters
|
||||
</Button>
|
||||
{showFilter && (
|
||||
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
|
||||
<Filter
|
||||
options={validationOptions}
|
||||
onClose={() => setShowFilter(false)}
|
||||
onFilterChange={(value) => {
|
||||
console.log('Selected filter:', value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Popover open={showFilter} onOpenChange={setShowFilter}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="secondary" size="md">
|
||||
<FilterIcon className="size-4" />
|
||||
Filters
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-auto p-0">
|
||||
<Filter
|
||||
options={validationOptions}
|
||||
title="Status"
|
||||
onClose={() => setShowFilter(false)}
|
||||
onFilterChange={(value) => {
|
||||
console.log('Selected filter:', value);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable data={mockTransactions} columns={columns} table={table} />
|
||||
</section>
|
||||
</main>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ModalValidate
|
||||
isOpen={showModalValidate}
|
||||
onClose={() => setShowModalValidate(false)}
|
||||
handleValid={() => {
|
||||
console.log('Action ketika user klik Valid')
|
||||
console.log('Action ketika user klik Valid');
|
||||
}}
|
||||
handleInvalid={() => {
|
||||
console.log('Action ketika user klik Tidak Valid')
|
||||
console.log('Action ketika user klik Tidak Valid');
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,23 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { DeleteOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import * as React from 'react';
|
||||
import { Eye, Search, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Input,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@imphnen-frontend-service/ui/atoms';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms'
|
||||
import { cn, For } from '@imphnen-frontend-service/utils'
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
@@ -13,98 +25,85 @@ import {
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { ReactElement, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
} from '@tanstack/react-table';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
useMentorList,
|
||||
useUserList,
|
||||
useDeleteMentor,
|
||||
MentorDetailResponseDto,
|
||||
TUsersListItem,
|
||||
} from '@imphnen-frontend-service/service'
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import {
|
||||
SelectAllCheckbox,
|
||||
RowSelectCheckbox,
|
||||
DeleteConfirmDialog,
|
||||
} from '../../components/list-helpers';
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/users-dimentorin')({
|
||||
component: UsersDimentorinPage,
|
||||
})
|
||||
});
|
||||
|
||||
function UsersDimentorinPage(): ReactElement {
|
||||
const TABS = ['mentor', 'mentee'] as const
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor')
|
||||
const [search, setSearch] = useState('')
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
function UsersDimentorinPage() {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = React.useState<'mentor' | 'mentee'>(
|
||||
'mentor'
|
||||
);
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [deletingId, setDeletingId] = React.useState<string | null>(null);
|
||||
const [rowSelection, setRowSelection] =
|
||||
React.useState<RowSelectionState>({});
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
})
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const { data: mentorData, isLoading: mentorLoading } = useMentorList({
|
||||
search,
|
||||
page: pagination.pageIndex + 1,
|
||||
per_page: pagination.pageSize,
|
||||
})
|
||||
|
||||
});
|
||||
const { data: menteeData, isLoading: menteeLoading } = useUserList({
|
||||
search,
|
||||
page: pagination.pageIndex + 1,
|
||||
per_page: pagination.pageSize,
|
||||
})
|
||||
});
|
||||
const deleteMentor = useDeleteMentor();
|
||||
|
||||
const deleteMentor = useDeleteMentor()
|
||||
|
||||
const mentors: MentorDetailResponseDto[] = mentorData?.data ?? []
|
||||
const mentees: TUsersListItem[] = menteeData?.data ?? []
|
||||
const mentorTotal = mentorData?.meta?.total ?? mentors.length
|
||||
const menteeTotal = menteeData?.meta?.total ?? mentees.length
|
||||
|
||||
const isLoading = activeTab === 'mentor' ? mentorLoading : menteeLoading
|
||||
const totalItems = activeTab === 'mentor' ? mentorTotal : menteeTotal
|
||||
const mentors: MentorDetailResponseDto[] = mentorData?.data ?? [];
|
||||
const mentees: TUsersListItem[] = menteeData?.data ?? [];
|
||||
const mentorTotal = mentorData?.meta?.total ?? mentors.length;
|
||||
const menteeTotal = menteeData?.meta?.total ?? mentees.length;
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteMentor.mutateAsync(id)
|
||||
toast.success('Akun berhasil dihapus')
|
||||
setDeletingId(null)
|
||||
await deleteMentor.mutateAsync(id);
|
||||
toast.success('Akun berhasil dihapus');
|
||||
setDeletingId(null);
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
toast.error('Gagal menghapus akun')
|
||||
console.log(error);
|
||||
toast.error('Gagal menghapus akun');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const statusVariantMap: Record<
|
||||
string,
|
||||
'success' | 'warning' | 'destructive' | 'secondary'
|
||||
> = {
|
||||
active: 'success',
|
||||
pending: 'warning',
|
||||
inactive: 'destructive',
|
||||
};
|
||||
|
||||
const mentorColumns: ColumnDef<MentorDetailResponseDto>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
meta: { cellClassName: cn('w-20') },
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
accessorKey: 'fullname',
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
header: 'Email',
|
||||
accessorKey: 'email',
|
||||
meta: { cellClassName: cn('w-10') },
|
||||
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||
},
|
||||
{ id: 'name', header: 'Name', accessorKey: 'fullname' },
|
||||
{ id: 'email', header: 'Email', accessorKey: 'email' },
|
||||
{
|
||||
id: 'rating',
|
||||
header: 'Rating',
|
||||
@@ -116,139 +115,86 @@ function UsersDimentorinPage(): ReactElement {
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status
|
||||
const statusColors: Record<string, string> = {
|
||||
active: 'bg-success-200 text-success-500',
|
||||
pending: 'bg-warning-200 text-warning-700',
|
||||
inactive: 'bg-danger-200 text-danger-500',
|
||||
}
|
||||
const status = row.original.status ?? '';
|
||||
return (
|
||||
<div className={`py-2 px-4 rounded-md text-center capitalize ${statusColors[status] ?? 'bg-neutral-200 text-neutral-700'}`}>
|
||||
{status}
|
||||
</div>
|
||||
)
|
||||
<Badge variant={statusVariantMap[status] ?? 'secondary'} className="capitalize">
|
||||
{status || 'unknown'}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
meta: { cellClassName: cn('w-72') },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-[8px]">
|
||||
{deletingId === row.original.id ? (
|
||||
<>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(row.original.id)
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Konfirmasi
|
||||
</Button>
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeletingId(null)
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate({ to: '/users-dimentorin/$id', params: { id: row.original.id } })
|
||||
}}
|
||||
className="flex items-center gap-2 w-max"
|
||||
>
|
||||
<SearchOutlined className="text-[16px]" /> Lihat Detail
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeletingId(row.original.id)
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate({
|
||||
to: '/users-dimentorin/$id',
|
||||
params: { id: row.original.id },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
Detail
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeletingId(row.original.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const menteeColumns: ColumnDef<TUsersListItem>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
meta: { cellClassName: cn('w-20') },
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
accessorKey: 'fullname',
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
header: 'Email',
|
||||
accessorKey: 'email',
|
||||
meta: { cellClassName: cn('w-10') },
|
||||
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||
},
|
||||
{ id: 'name', header: 'Name', accessorKey: 'fullname' },
|
||||
{ id: 'email', header: 'Email', accessorKey: 'email' },
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
accessorKey: 'is_active',
|
||||
cell: ({ row }) => (
|
||||
<div className={`py-2 px-4 rounded-md text-center ${row.original.is_active ? 'bg-success-200 text-success-500' : 'bg-danger-200 text-danger-500'}`}>
|
||||
<Badge variant={row.original.is_active ? 'success' : 'destructive'}>
|
||||
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||
</div>
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
meta: { cellClassName: cn('w-72') },
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate({ to: '/users-dimentorin/$id', params: { id: row.original.id } })
|
||||
e.stopPropagation();
|
||||
navigate({
|
||||
to: '/users-dimentorin/$id',
|
||||
params: { id: row.original.id },
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-2 w-max"
|
||||
>
|
||||
<SearchOutlined className="text-[16px]" /> Lihat Detail
|
||||
<Eye className="size-3.5" />
|
||||
Detail
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const mentorTable = useReactTable({
|
||||
data: mentors,
|
||||
@@ -261,7 +207,7 @@ function UsersDimentorinPage(): ReactElement {
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(mentorTotal / pagination.pageSize),
|
||||
manualPagination: true,
|
||||
})
|
||||
});
|
||||
|
||||
const menteeTable = useReactTable({
|
||||
data: mentees,
|
||||
@@ -274,59 +220,91 @@ function UsersDimentorinPage(): ReactElement {
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(menteeTotal / pagination.pageSize),
|
||||
manualPagination: true,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="Dimentorin.dev">
|
||||
<div className="mb-8 flex justify-between items-center">
|
||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">
|
||||
User Management
|
||||
</h1>
|
||||
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
||||
<For data={TABS}>
|
||||
{(tab) => (
|
||||
<Button
|
||||
key={tab}
|
||||
variant="text"
|
||||
className={cn(
|
||||
'px-3 py-2 capitalize',
|
||||
activeTab === tab && 'bg-white'
|
||||
)}
|
||||
onClick={() => {
|
||||
setActiveTab(tab)
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }))
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
</Button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
<div className="flex justify-between items-center gap-5 mb-2">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama lengkap"
|
||||
className="pl-12 w-full max-h-full"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||
<SearchOutlined />
|
||||
<BackofficeWrapper
|
||||
title="Users Dimentorin"
|
||||
description="Manajemen mentor dan mentee"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Cari nama lengkap…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => {
|
||||
setActiveTab(v as 'mentor' | 'mentee');
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="mentor" className="capitalize">
|
||||
Mentor ({mentorTotal})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mentee" className="capitalize">
|
||||
Mentee ({menteeTotal})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="mentor" className="mt-4">
|
||||
{mentorLoading ? (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
Memuat data…
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={mentors}
|
||||
columns={mentorColumns}
|
||||
table={mentorTable}
|
||||
manualPagination
|
||||
pageCount={Math.ceil(mentorTotal / pagination.pageSize)}
|
||||
currentPage={pagination.pageIndex + 1}
|
||||
onPageChange={(p) =>
|
||||
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="mentee" className="mt-4">
|
||||
{menteeLoading ? (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
Memuat data…
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={mentees}
|
||||
columns={menteeColumns}
|
||||
table={menteeTable}
|
||||
manualPagination
|
||||
pageCount={Math.ceil(menteeTotal / pagination.pageSize)}
|
||||
currentPage={pagination.pageIndex + 1}
|
||||
onPageChange={(p) =>
|
||||
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
||||
) : activeTab === 'mentor' ? (
|
||||
<DataTable data={mentors} columns={mentorColumns} table={mentorTable} />
|
||||
) : (
|
||||
<DataTable data={mentees} columns={menteeColumns} table={menteeTable} />
|
||||
)}
|
||||
</section>
|
||||
<DeleteConfirmDialog
|
||||
open={!!deletingId}
|
||||
onOpenChange={(o) => !o && setDeletingId(null)}
|
||||
onConfirm={() => deletingId && handleDelete(deletingId)}
|
||||
title="Hapus akun mentor ini?"
|
||||
/>
|
||||
</BackofficeWrapper>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,30 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { SessionToken } from '@imphnen-frontend-service/service'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
beforeLoad: () => {
|
||||
const session = SessionToken.get()
|
||||
if (session?.token?.access_token) {
|
||||
throw redirect({ to: '/hackathon-dashboard' })
|
||||
}
|
||||
throw redirect({ to: '/auth/login' })
|
||||
},
|
||||
component: IndexRedirect,
|
||||
})
|
||||
|
||||
function IndexRedirect() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const session = SessionToken.get()
|
||||
if (session?.token?.access_token) {
|
||||
navigate({ to: '/hackathon-dashboard' })
|
||||
} else {
|
||||
navigate({ to: '/auth/login' })
|
||||
}
|
||||
} catch {
|
||||
navigate({ to: '/auth/login' })
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
||||
<div style={{ width: 40, height: 40, border: '3px solid #e5e7eb', borderTopColor: '#3b82f6', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,7 +16,9 @@ export default defineConfig(() => ({
|
||||
port: 3001,
|
||||
host: 'localhost',
|
||||
},
|
||||
build: { rolldownOptions: { output: { codeSplitting: { groups: [{ name: 'vendor', test: /node_modules/ }] } } } },
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom', '@tanstack/react-router', '@tanstack/react-query'],
|
||||
},
|
||||
plugins: [
|
||||
TanStackRouterVite({
|
||||
routeFileIgnorePattern: '_components|_hooks|_hook|_data',
|
||||
@@ -32,6 +34,9 @@ export default defineConfig(() => ({
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
rolldownOptions: {
|
||||
output: { codeSplitting: { groups: [{ name: 'vendor', test: /node_modules/ }] } },
|
||||
},
|
||||
},
|
||||
test: {
|
||||
watch: false,
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
VITE_API_URL=
|
||||
VITE_API_URL=
|
||||
VITE_DISABLE_AUTH=false
|
||||
VITE_GITHUB_CLIENT_ID=
|
||||
@@ -0,0 +1,141 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Test configuration for dashboard screenshot capture
|
||||
*/
|
||||
interface DashboardTestCase {
|
||||
persona: 'user' | 'mentor';
|
||||
viewport: 'desktop' | 'mobile';
|
||||
roleName: string;
|
||||
waitForText: string;
|
||||
screenshotFileName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock authentication token structure
|
||||
*/
|
||||
const mockToken = {
|
||||
access_token: 'mock_access_token_' + Math.random().toString(36).substring(7),
|
||||
refresh_token: 'mock_refresh_token_' + Math.random().toString(36).substring(7),
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a mock user object based on persona
|
||||
*/
|
||||
function createMockUser(persona: 'user' | 'mentor') {
|
||||
const basUser = {
|
||||
id: 'mock-user-id',
|
||||
email: 'test@imphnen.com',
|
||||
fullname: 'Test User',
|
||||
is_active: true,
|
||||
role: {
|
||||
id: 'role-' + persona,
|
||||
name: persona === 'mentor' ? 'Mentor Role' : 'User Role',
|
||||
permissions: [],
|
||||
},
|
||||
};
|
||||
return basUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard test cases
|
||||
*/
|
||||
const testCases: DashboardTestCase[] = [
|
||||
{
|
||||
persona: 'user',
|
||||
viewport: 'desktop',
|
||||
roleName: 'User Role',
|
||||
waitForText: 'Roadmaps',
|
||||
screenshotFileName: 'dashboard_user_mock_desktop.png',
|
||||
},
|
||||
{
|
||||
persona: 'mentor',
|
||||
viewport: 'desktop',
|
||||
roleName: 'Mentor Role',
|
||||
waitForText: 'Analytics',
|
||||
screenshotFileName: 'dashboard_mentor_mock_desktop.png',
|
||||
},
|
||||
{
|
||||
persona: 'user',
|
||||
viewport: 'mobile',
|
||||
roleName: 'User Role',
|
||||
waitForText: 'Roadmaps',
|
||||
screenshotFileName: 'dashboard_user_mock_mobile.png',
|
||||
},
|
||||
{
|
||||
persona: 'mentor',
|
||||
viewport: 'mobile',
|
||||
roleName: 'Mentor Role',
|
||||
waitForText: 'Analytics',
|
||||
screenshotFileName: 'dashboard_mentor_mock_mobile.png',
|
||||
},
|
||||
];
|
||||
|
||||
test.describe('Dashboard Screenshot Capture', () => {
|
||||
const screenshotDir = path.resolve(__dirname, '../screenshots');
|
||||
|
||||
test.beforeAll(() => {
|
||||
if (!fs.existsSync(screenshotDir)) {
|
||||
fs.mkdirSync(screenshotDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Run test for each dashboard variant
|
||||
*/
|
||||
for (const testCase of testCases) {
|
||||
test(`capture ${testCase.persona} ${testCase.viewport} dashboard screenshot`, async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Set viewport size based on device type
|
||||
const viewportSize = testCase.viewport === 'desktop' ? { width: 1280, height: 832 } : { width: 375, height: 667 };
|
||||
await page.setViewportSize(viewportSize);
|
||||
|
||||
// Set authentication cookie with mock token
|
||||
const tokenCookie = {
|
||||
name: 'token',
|
||||
value: JSON.stringify({ token: mockToken }),
|
||||
url: 'http://localhost:3000',
|
||||
secure: false,
|
||||
httpOnly: false,
|
||||
sameSite: 'Strict' as const,
|
||||
};
|
||||
await context.addCookies([tokenCookie]);
|
||||
|
||||
// Set user in localStorage
|
||||
const mockUser = createMockUser(testCase.persona);
|
||||
await page.goto('http://localhost:3000', { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(
|
||||
({ user }) => {
|
||||
localStorage.setItem('users', JSON.stringify(user));
|
||||
},
|
||||
{ user: mockUser }
|
||||
);
|
||||
|
||||
// Navigate to dashboard with persona parameter for explicit override
|
||||
const dashboardUrl = `http://localhost:3000/dashboard?persona=${testCase.persona}`;
|
||||
await page.goto(dashboardUrl, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
|
||||
// Wait for stable dashboard content
|
||||
await page.waitForSelector(
|
||||
`text="${testCase.waitForText}"`,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
// Additional wait for content to render
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Capture screenshot
|
||||
const screenshotPath = path.join(screenshotDir, testCase.screenshotFileName);
|
||||
await page.screenshot({
|
||||
path: screenshotPath,
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
console.log(`✓ Captured: ${testCase.screenshotFileName}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Dynamically extract all routes from the TanStack Router generated file.
|
||||
*/
|
||||
function getDynamicRoutes(): string[] {
|
||||
const routeTreePath = path.resolve(__dirname, '../src/routeTree.gen.ts');
|
||||
if (!fs.existsSync(routeTreePath)) {
|
||||
console.warn('Route tree file not found, falling back to basic routes');
|
||||
return ['/'];
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(routeTreePath, 'utf-8');
|
||||
|
||||
// Extract the FileRoutesByFullPath interface block
|
||||
const interfaceMatch = content.match(/export interface FileRoutesByFullPath \{([\s\S]*?)\}/);
|
||||
if (!interfaceMatch) return ['/'];
|
||||
|
||||
const block = interfaceMatch[1];
|
||||
|
||||
// Extract all strings in single quotes
|
||||
const routeMatches = block.match(/'([^']+)'/g);
|
||||
if (!routeMatches) return ['/'];
|
||||
|
||||
return routeMatches.map(m => {
|
||||
let route = m.replace(/'/g, '');
|
||||
|
||||
// Normalize trailing slashes (Optional cleanup)
|
||||
if (route !== '/' && route.endsWith('/')) {
|
||||
route = route.slice(0, -1);
|
||||
}
|
||||
|
||||
// Replace dynamic parameters with sample values
|
||||
return route
|
||||
.replace(/\$slug/g, 'sample-article')
|
||||
.replace(/\$id/g, 'sample-id')
|
||||
.replace(/\$taskId/g, 'sample-task');
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Automated Route Discovery & Screenshots', () => {
|
||||
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
|
||||
const screenshotDir = path.resolve(__dirname, '../screenshots');
|
||||
const routes = [...new Set(getDynamicRoutes())]; // Unique routes
|
||||
|
||||
test.beforeAll(() => {
|
||||
if (!fs.existsSync(screenshotDir)) {
|
||||
fs.mkdirSync(screenshotDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Discovered ${routes.length} routes for processing.`);
|
||||
|
||||
for (const route of routes) {
|
||||
test(`capture screenshot: ${route}`, async ({ page }) => {
|
||||
console.log(`Processing: ${baseURL}${route}`);
|
||||
|
||||
try {
|
||||
// Mock auth session if accessing protected routes
|
||||
if (route.startsWith('/dashboard') || route.startsWith('/profile')) {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('auth-storage', JSON.stringify({
|
||||
state: {
|
||||
session: {
|
||||
user: {
|
||||
id: 'sample-user-id',
|
||||
fullname: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
avatar: '/image/mascot-character.webp'
|
||||
},
|
||||
token: 'mock-token'
|
||||
}
|
||||
},
|
||||
version: 0
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
await page.goto(`${baseURL}${route}`, {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 60000
|
||||
});
|
||||
|
||||
// Wait for rendering
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const fileName = route.replace(/\//g, '_').replace(/^_/, '').replace(/[:$]/g, '') || 'home';
|
||||
const screenshotPath = path.join(screenshotDir, `${fileName}.png`);
|
||||
|
||||
await page.screenshot({
|
||||
path: screenshotPath,
|
||||
fullPage: true
|
||||
});
|
||||
|
||||
console.log(`Success: ${fileName}.png`);
|
||||
|
||||
// --- Custom logic for Modals and Tabs ---
|
||||
|
||||
// 1. Mentoring Modals
|
||||
if (route === '/dashboard/mentoring') {
|
||||
// Detail modal
|
||||
const detailButton = page.getByRole('button', { name: 'Cek Detail' }).first();
|
||||
if (await detailButton.isVisible()) {
|
||||
await detailButton.click({ timeout: 5000 });
|
||||
const detailModal = page.locator('div.fixed.inset-0').filter({ hasText: 'Detail Sesi Mentoring' }).first();
|
||||
await detailModal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: path.join(screenshotDir, 'dashboard_mentoring_modal_detail.png') });
|
||||
await detailModal.locator('button').first().click({ timeout: 5000 });
|
||||
await detailModal.waitFor({ state: 'hidden', timeout: 5000 });
|
||||
}
|
||||
|
||||
// Contact modal
|
||||
const cancelButton = page.getByRole('button', { name: 'Cancel' }).first();
|
||||
if (await cancelButton.isVisible()) {
|
||||
await cancelButton.click({ timeout: 5000 });
|
||||
const contactModal = page.locator('div.fixed.inset-0').filter({ hasText: 'Hubungi mentor melalui platform berikut' }).first();
|
||||
await contactModal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: path.join(screenshotDir, 'dashboard_mentoring_modal_contact.png') });
|
||||
await contactModal.getByRole('button', { name: 'Tutup' }).click({ timeout: 5000 });
|
||||
await contactModal.waitFor({ state: 'hidden', timeout: 5000 });
|
||||
}
|
||||
|
||||
// Feedback modal
|
||||
const feedbackBtn = page.getByRole('button', { name: 'Kirim Feedback' }).first();
|
||||
if (await feedbackBtn.isVisible()) {
|
||||
await feedbackBtn.click({ timeout: 5000 });
|
||||
const feedbackModal = page
|
||||
.locator('div.fixed.inset-0')
|
||||
.filter({ hasText: /Beri Feedback|Feedback Mentor/ })
|
||||
.first();
|
||||
await feedbackModal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: path.join(screenshotDir, 'dashboard_mentoring_modal_feedback.png') });
|
||||
const closeFeedbackBtn = feedbackModal.getByRole('button', { name: 'Batal' });
|
||||
if (await closeFeedbackBtn.isVisible()) {
|
||||
await closeFeedbackBtn.click({ timeout: 5000 });
|
||||
await feedbackModal.waitFor({ state: 'hidden', timeout: 5000 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Learning Path Tabs
|
||||
if (route === '/dashboard/learning-path') {
|
||||
await page.click('button:has-text("Article")');
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: path.join(screenshotDir, 'dashboard_learning-path_tab_article.png') });
|
||||
}
|
||||
|
||||
// 3. Settings Sections & 2FA Modals
|
||||
if (route === '/dashboard/settings') {
|
||||
const sections = ['Privasi & Keamanan', 'FAQ', 'Laporkan Kendala', 'Umpan Balik'];
|
||||
for (const section of sections) {
|
||||
const sectionButton = page.getByRole('button', { name: section }).first();
|
||||
if (!(await sectionButton.isVisible())) {
|
||||
console.warn(`Section button not found: ${section}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await sectionButton.click({ timeout: 5000 });
|
||||
await page.waitForTimeout(300);
|
||||
const slug = section
|
||||
.toLowerCase()
|
||||
.replace(/&/g, 'and')
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^a-z0-9_]/g, '');
|
||||
await page.screenshot({ path: path.join(screenshotDir, `dashboard_settings_section_${slug}.png`) });
|
||||
|
||||
// If in Privacy, test 2FA modal
|
||||
if (section === 'Privasi & Keamanan') {
|
||||
const enable2FaButton = page.getByRole('button', { name: 'Aktifkan 2FA' }).first();
|
||||
if (!(await enable2FaButton.isVisible())) {
|
||||
console.warn('2FA trigger button not found');
|
||||
continue;
|
||||
}
|
||||
|
||||
await enable2FaButton.click({ timeout: 5000 });
|
||||
const emailModal = page.locator('div.fixed.inset-0').filter({ hasText: 'Verifikasi Email' }).first();
|
||||
await emailModal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: path.join(screenshotDir, 'dashboard_settings_modal_2fa_step1.png') });
|
||||
|
||||
await emailModal.getByRole('button', { name: 'Kirim Kode' }).click({ timeout: 5000 });
|
||||
const otpModal = page.locator('div.fixed.inset-0').filter({ hasText: 'Masukkan Kode OTP' }).first();
|
||||
await otpModal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: path.join(screenshotDir, 'dashboard_settings_modal_2fa_step2.png') });
|
||||
|
||||
await otpModal.locator('button').first().click({ timeout: 5000 });
|
||||
await otpModal.waitFor({ state: 'hidden', timeout: 5000 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Header Notifications
|
||||
if (route === '/dashboard') {
|
||||
const notificationButton = page.locator('header button').first();
|
||||
if (await notificationButton.isVisible()) {
|
||||
await notificationButton.click({ timeout: 5000 });
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: path.join(screenshotDir, 'dashboard_header_notifications.png') });
|
||||
} else {
|
||||
console.warn('Notification button not found on dashboard header');
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to capture ${route}:`, error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: 'npx nx dev dimentorin',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: true,
|
||||
cwd: '../../', // Run from root where Nx is available
|
||||
timeout: 120000,
|
||||
env: {
|
||||
VITE_BYPASS_AUTH_MIDDLEWARE: 'true',
|
||||
},
|
||||
},
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -87,6 +87,22 @@
|
||||
--text-p2: 1.23rem; /* ~19.68px */
|
||||
--text-label1: 0.98rem; /* ~15.74px */
|
||||
--text-label2: 0.78rem; /* ~12.59px */
|
||||
|
||||
/* Custom Colors for CSS Hex Exact Matches - IMPHNEN Dimentorin Brand */
|
||||
--color-bg-light-blue: #f7fbff; /* Primary dashboard background */
|
||||
--color-primary-accent: #23a1eb; /* Primary interactive color (buttons, links, active states) */
|
||||
--color-text-dark: #1a1a1a; /* Ultra-dark text (auth titles, headers) */
|
||||
--color-text-secondary: #6d6d6d; /* Secondary text (subtitles, descriptions) */
|
||||
--color-text-label: #454545; /* Form labels and descriptive labels */
|
||||
--color-text-muted: #888888; /* Muted text (nav items, metric labels) */
|
||||
--color-border-light: #bce1fb; /* Light blue borders (inputs, tabs) */
|
||||
--color-placeholder: #b0b0b0; /* Input placeholder text, disabled backgrounds */
|
||||
--color-bg-hover: #f6f6f6; /* Hover state backgrounds (nav items) */
|
||||
--color-divider-blue: #81cbf8; /* Divider lines and gradient fills */
|
||||
--color-border-subtle: #e5e5e5; /* Very light borders (buttons) */
|
||||
--color-bg-secondary: #f8f8f8; /* Secondary backgrounds (alt button) */
|
||||
--color-bg-placeholder: #d9d9d9; /* Placeholder backgrounds (avatars, images) */
|
||||
--color-text-tab-default: #4f4f4f; /* Tab button text color */
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -124,4 +140,8 @@
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.shadow-auth {
|
||||
box-shadow: 0 10px 40px rgba(35, 161, 235, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { Route as SiteProfileRouteImport } from './routes/_site/profile'
|
||||
import { Route as SiteMentoringRouteImport } from './routes/_site/mentoring'
|
||||
import { Route as SiteArticlesRouteImport } from './routes/_site/articles'
|
||||
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
|
||||
import { Route as AuthenticatedDashboardIndexRouteImport } from './routes/_authenticated/dashboard_/index'
|
||||
import { Route as AuthenticatedDashboardIndexRouteImport } from './routes/_authenticated/dashboard/index'
|
||||
import { Route as SiteProfileIdRouteImport } from './routes/_site/profile_/$id'
|
||||
import { Route as SiteMentoringIdRouteImport } from './routes/_site/mentoring_/$id'
|
||||
import { Route as SiteArticlesSlugRouteImport } from './routes/_site/articles_/$slug'
|
||||
@@ -29,6 +29,10 @@ import { Route as PublicAuthGoogleOauthPopupRouteImport } from './routes/_public
|
||||
import { Route as PublicAuthGoogleCallbackRouteImport } from './routes/_public/auth/google-callback'
|
||||
import { Route as PublicAuthForgotRouteImport } from './routes/_public/auth/forgot'
|
||||
import { Route as AuthenticatedDashboardSettingsRouteImport } from './routes/_authenticated/dashboard_/settings'
|
||||
import { Route as AuthenticatedDashboardRoadmapDiscoveryRouteImport } from './routes/_authenticated/dashboard/roadmap-discovery'
|
||||
import { Route as AuthenticatedDashboardMentoringRouteImport } from './routes/_authenticated/dashboard/mentoring'
|
||||
import { Route as AuthenticatedDashboardLearningPathRouteImport } from './routes/_authenticated/dashboard/learning-path'
|
||||
import { Route as AuthenticatedDashboardArticleBuilderRouteImport } from './routes/_authenticated/dashboard/article-builder'
|
||||
import { Route as PublicAuthRegisterSuccessRouteImport } from './routes/_public/auth/register_/success'
|
||||
import { Route as PublicAuthRegisterOtpRouteImport } from './routes/_public/auth/register_/otp'
|
||||
import { Route as PublicAuthRegisterMentorSuccessRouteImport } from './routes/_public/auth/register-mentor_/success'
|
||||
@@ -80,9 +84,9 @@ const AuthenticatedDashboardRoute = AuthenticatedDashboardRouteImport.update({
|
||||
} as any)
|
||||
const AuthenticatedDashboardIndexRoute =
|
||||
AuthenticatedDashboardIndexRouteImport.update({
|
||||
id: '/dashboard_/',
|
||||
path: '/dashboard/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AuthenticatedDashboardRoute,
|
||||
} as any)
|
||||
const SiteProfileIdRoute = SiteProfileIdRouteImport.update({
|
||||
id: '/profile_/$id',
|
||||
@@ -138,6 +142,30 @@ const AuthenticatedDashboardSettingsRoute =
|
||||
path: '/dashboard/settings',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedDashboardRoadmapDiscoveryRoute =
|
||||
AuthenticatedDashboardRoadmapDiscoveryRouteImport.update({
|
||||
id: '/roadmap-discovery',
|
||||
path: '/roadmap-discovery',
|
||||
getParentRoute: () => AuthenticatedDashboardRoute,
|
||||
} as any)
|
||||
const AuthenticatedDashboardMentoringRoute =
|
||||
AuthenticatedDashboardMentoringRouteImport.update({
|
||||
id: '/mentoring',
|
||||
path: '/mentoring',
|
||||
getParentRoute: () => AuthenticatedDashboardRoute,
|
||||
} as any)
|
||||
const AuthenticatedDashboardLearningPathRoute =
|
||||
AuthenticatedDashboardLearningPathRouteImport.update({
|
||||
id: '/learning-path',
|
||||
path: '/learning-path',
|
||||
getParentRoute: () => AuthenticatedDashboardRoute,
|
||||
} as any)
|
||||
const AuthenticatedDashboardArticleBuilderRoute =
|
||||
AuthenticatedDashboardArticleBuilderRouteImport.update({
|
||||
id: '/article-builder',
|
||||
path: '/article-builder',
|
||||
getParentRoute: () => AuthenticatedDashboardRoute,
|
||||
} as any)
|
||||
const PublicAuthRegisterSuccessRoute =
|
||||
PublicAuthRegisterSuccessRouteImport.update({
|
||||
id: '/auth/register_/success',
|
||||
@@ -174,11 +202,15 @@ const PublicAuthForgotOtpRoute = PublicAuthForgotOtpRouteImport.update({
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/dashboard': typeof AuthenticatedDashboardRoute
|
||||
'/dashboard': typeof AuthenticatedDashboardRouteWithChildren
|
||||
'/articles': typeof SiteArticlesRoute
|
||||
'/mentoring': typeof SiteMentoringRoute
|
||||
'/profile': typeof SiteProfileRoute
|
||||
'/resources': typeof SiteResourcesRoute
|
||||
'/dashboard/article-builder': typeof AuthenticatedDashboardArticleBuilderRoute
|
||||
'/dashboard/learning-path': typeof AuthenticatedDashboardLearningPathRoute
|
||||
'/dashboard/mentoring': typeof AuthenticatedDashboardMentoringRoute
|
||||
'/dashboard/roadmap-discovery': typeof AuthenticatedDashboardRoadmapDiscoveryRoute
|
||||
'/dashboard/settings': typeof AuthenticatedDashboardSettingsRoute
|
||||
'/auth/forgot': typeof PublicAuthForgotRoute
|
||||
'/auth/google-callback': typeof PublicAuthGoogleCallbackRoute
|
||||
@@ -199,11 +231,14 @@ export interface FileRoutesByFullPath {
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/dashboard': typeof AuthenticatedDashboardIndexRoute
|
||||
'/articles': typeof SiteArticlesRoute
|
||||
'/mentoring': typeof SiteMentoringRoute
|
||||
'/profile': typeof SiteProfileRoute
|
||||
'/resources': typeof SiteResourcesRoute
|
||||
'/dashboard/article-builder': typeof AuthenticatedDashboardArticleBuilderRoute
|
||||
'/dashboard/learning-path': typeof AuthenticatedDashboardLearningPathRoute
|
||||
'/dashboard/mentoring': typeof AuthenticatedDashboardMentoringRoute
|
||||
'/dashboard/roadmap-discovery': typeof AuthenticatedDashboardRoadmapDiscoveryRoute
|
||||
'/dashboard/settings': typeof AuthenticatedDashboardSettingsRoute
|
||||
'/auth/forgot': typeof PublicAuthForgotRoute
|
||||
'/auth/google-callback': typeof PublicAuthGoogleCallbackRoute
|
||||
@@ -214,6 +249,7 @@ export interface FileRoutesByTo {
|
||||
'/articles/$slug': typeof SiteArticlesSlugRoute
|
||||
'/mentoring/$id': typeof SiteMentoringIdRoute
|
||||
'/profile/$id': typeof SiteProfileIdRoute
|
||||
'/dashboard': typeof AuthenticatedDashboardIndexRoute
|
||||
'/auth/forgot/otp': typeof PublicAuthForgotOtpRoute
|
||||
'/auth/forgot/summon': typeof PublicAuthForgotSummonRoute
|
||||
'/auth/register-mentor/pending': typeof PublicAuthRegisterMentorPendingRoute
|
||||
@@ -227,11 +263,15 @@ export interface FileRoutesById {
|
||||
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
||||
'/_public': typeof PublicRouteWithChildren
|
||||
'/_site': typeof SiteRouteWithChildren
|
||||
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
|
||||
'/_authenticated/dashboard': typeof AuthenticatedDashboardRouteWithChildren
|
||||
'/_site/articles': typeof SiteArticlesRoute
|
||||
'/_site/mentoring': typeof SiteMentoringRoute
|
||||
'/_site/profile': typeof SiteProfileRoute
|
||||
'/_site/resources': typeof SiteResourcesRoute
|
||||
'/_authenticated/dashboard/article-builder': typeof AuthenticatedDashboardArticleBuilderRoute
|
||||
'/_authenticated/dashboard/learning-path': typeof AuthenticatedDashboardLearningPathRoute
|
||||
'/_authenticated/dashboard/mentoring': typeof AuthenticatedDashboardMentoringRoute
|
||||
'/_authenticated/dashboard/roadmap-discovery': typeof AuthenticatedDashboardRoadmapDiscoveryRoute
|
||||
'/_authenticated/dashboard_/settings': typeof AuthenticatedDashboardSettingsRoute
|
||||
'/_public/auth/forgot': typeof PublicAuthForgotRoute
|
||||
'/_public/auth/google-callback': typeof PublicAuthGoogleCallbackRoute
|
||||
@@ -242,7 +282,7 @@ export interface FileRoutesById {
|
||||
'/_site/articles_/$slug': typeof SiteArticlesSlugRoute
|
||||
'/_site/mentoring_/$id': typeof SiteMentoringIdRoute
|
||||
'/_site/profile_/$id': typeof SiteProfileIdRoute
|
||||
'/_authenticated/dashboard_/': typeof AuthenticatedDashboardIndexRoute
|
||||
'/_authenticated/dashboard/': typeof AuthenticatedDashboardIndexRoute
|
||||
'/_public/auth/forgot_/otp': typeof PublicAuthForgotOtpRoute
|
||||
'/_public/auth/forgot_/summon': typeof PublicAuthForgotSummonRoute
|
||||
'/_public/auth/register-mentor_/pending': typeof PublicAuthRegisterMentorPendingRoute
|
||||
@@ -259,6 +299,10 @@ export interface FileRouteTypes {
|
||||
| '/mentoring'
|
||||
| '/profile'
|
||||
| '/resources'
|
||||
| '/dashboard/article-builder'
|
||||
| '/dashboard/learning-path'
|
||||
| '/dashboard/mentoring'
|
||||
| '/dashboard/roadmap-discovery'
|
||||
| '/dashboard/settings'
|
||||
| '/auth/forgot'
|
||||
| '/auth/google-callback'
|
||||
@@ -279,11 +323,14 @@ export interface FileRouteTypes {
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/dashboard'
|
||||
| '/articles'
|
||||
| '/mentoring'
|
||||
| '/profile'
|
||||
| '/resources'
|
||||
| '/dashboard/article-builder'
|
||||
| '/dashboard/learning-path'
|
||||
| '/dashboard/mentoring'
|
||||
| '/dashboard/roadmap-discovery'
|
||||
| '/dashboard/settings'
|
||||
| '/auth/forgot'
|
||||
| '/auth/google-callback'
|
||||
@@ -294,6 +341,7 @@ export interface FileRouteTypes {
|
||||
| '/articles/$slug'
|
||||
| '/mentoring/$id'
|
||||
| '/profile/$id'
|
||||
| '/dashboard'
|
||||
| '/auth/forgot/otp'
|
||||
| '/auth/forgot/summon'
|
||||
| '/auth/register-mentor/pending'
|
||||
@@ -311,6 +359,10 @@ export interface FileRouteTypes {
|
||||
| '/_site/mentoring'
|
||||
| '/_site/profile'
|
||||
| '/_site/resources'
|
||||
| '/_authenticated/dashboard/article-builder'
|
||||
| '/_authenticated/dashboard/learning-path'
|
||||
| '/_authenticated/dashboard/mentoring'
|
||||
| '/_authenticated/dashboard/roadmap-discovery'
|
||||
| '/_authenticated/dashboard_/settings'
|
||||
| '/_public/auth/forgot'
|
||||
| '/_public/auth/google-callback'
|
||||
@@ -321,7 +373,7 @@ export interface FileRouteTypes {
|
||||
| '/_site/articles_/$slug'
|
||||
| '/_site/mentoring_/$id'
|
||||
| '/_site/profile_/$id'
|
||||
| '/_authenticated/dashboard_/'
|
||||
| '/_authenticated/dashboard/'
|
||||
| '/_public/auth/forgot_/otp'
|
||||
| '/_public/auth/forgot_/summon'
|
||||
| '/_public/auth/register-mentor_/pending'
|
||||
@@ -402,12 +454,12 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedDashboardRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/dashboard_/': {
|
||||
id: '/_authenticated/dashboard_/'
|
||||
path: '/dashboard'
|
||||
'/_authenticated/dashboard/': {
|
||||
id: '/_authenticated/dashboard/'
|
||||
path: '/'
|
||||
fullPath: '/dashboard/'
|
||||
preLoaderRoute: typeof AuthenticatedDashboardIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
parentRoute: typeof AuthenticatedDashboardRoute
|
||||
}
|
||||
'/_site/profile_/$id': {
|
||||
id: '/_site/profile_/$id'
|
||||
@@ -479,6 +531,34 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedDashboardSettingsRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/dashboard/roadmap-discovery': {
|
||||
id: '/_authenticated/dashboard/roadmap-discovery'
|
||||
path: '/roadmap-discovery'
|
||||
fullPath: '/dashboard/roadmap-discovery'
|
||||
preLoaderRoute: typeof AuthenticatedDashboardRoadmapDiscoveryRouteImport
|
||||
parentRoute: typeof AuthenticatedDashboardRoute
|
||||
}
|
||||
'/_authenticated/dashboard/mentoring': {
|
||||
id: '/_authenticated/dashboard/mentoring'
|
||||
path: '/mentoring'
|
||||
fullPath: '/dashboard/mentoring'
|
||||
preLoaderRoute: typeof AuthenticatedDashboardMentoringRouteImport
|
||||
parentRoute: typeof AuthenticatedDashboardRoute
|
||||
}
|
||||
'/_authenticated/dashboard/learning-path': {
|
||||
id: '/_authenticated/dashboard/learning-path'
|
||||
path: '/learning-path'
|
||||
fullPath: '/dashboard/learning-path'
|
||||
preLoaderRoute: typeof AuthenticatedDashboardLearningPathRouteImport
|
||||
parentRoute: typeof AuthenticatedDashboardRoute
|
||||
}
|
||||
'/_authenticated/dashboard/article-builder': {
|
||||
id: '/_authenticated/dashboard/article-builder'
|
||||
path: '/article-builder'
|
||||
fullPath: '/dashboard/article-builder'
|
||||
preLoaderRoute: typeof AuthenticatedDashboardArticleBuilderRouteImport
|
||||
parentRoute: typeof AuthenticatedDashboardRoute
|
||||
}
|
||||
'/_public/auth/register_/success': {
|
||||
id: '/_public/auth/register_/success'
|
||||
path: '/auth/register/success'
|
||||
@@ -524,16 +604,39 @@ declare module '@tanstack/react-router' {
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthenticatedRouteChildren {
|
||||
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
|
||||
AuthenticatedDashboardSettingsRoute: typeof AuthenticatedDashboardSettingsRoute
|
||||
interface AuthenticatedDashboardRouteChildren {
|
||||
AuthenticatedDashboardArticleBuilderRoute: typeof AuthenticatedDashboardArticleBuilderRoute
|
||||
AuthenticatedDashboardLearningPathRoute: typeof AuthenticatedDashboardLearningPathRoute
|
||||
AuthenticatedDashboardMentoringRoute: typeof AuthenticatedDashboardMentoringRoute
|
||||
AuthenticatedDashboardRoadmapDiscoveryRoute: typeof AuthenticatedDashboardRoadmapDiscoveryRoute
|
||||
AuthenticatedDashboardIndexRoute: typeof AuthenticatedDashboardIndexRoute
|
||||
}
|
||||
|
||||
const AuthenticatedDashboardRouteChildren: AuthenticatedDashboardRouteChildren =
|
||||
{
|
||||
AuthenticatedDashboardArticleBuilderRoute:
|
||||
AuthenticatedDashboardArticleBuilderRoute,
|
||||
AuthenticatedDashboardLearningPathRoute:
|
||||
AuthenticatedDashboardLearningPathRoute,
|
||||
AuthenticatedDashboardMentoringRoute: AuthenticatedDashboardMentoringRoute,
|
||||
AuthenticatedDashboardRoadmapDiscoveryRoute:
|
||||
AuthenticatedDashboardRoadmapDiscoveryRoute,
|
||||
AuthenticatedDashboardIndexRoute: AuthenticatedDashboardIndexRoute,
|
||||
}
|
||||
|
||||
const AuthenticatedDashboardRouteWithChildren =
|
||||
AuthenticatedDashboardRoute._addFileChildren(
|
||||
AuthenticatedDashboardRouteChildren,
|
||||
)
|
||||
|
||||
interface AuthenticatedRouteChildren {
|
||||
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRouteWithChildren
|
||||
AuthenticatedDashboardSettingsRoute: typeof AuthenticatedDashboardSettingsRoute
|
||||
}
|
||||
|
||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
|
||||
AuthenticatedDashboardRoute: AuthenticatedDashboardRouteWithChildren,
|
||||
AuthenticatedDashboardSettingsRoute: AuthenticatedDashboardSettingsRoute,
|
||||
AuthenticatedDashboardIndexRoute: AuthenticatedDashboardIndexRoute,
|
||||
}
|
||||
|
||||
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
||||
|
||||
@@ -3,6 +3,13 @@ import { SessionToken } from '@imphnen-frontend-service/service'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated')({
|
||||
beforeLoad: () => {
|
||||
// Development-only bypass explicitly requested via environment flags
|
||||
const bypassAuth = import.meta.env.MODE === 'development' && import.meta.env.VITE_BYPASS_AUTH_MIDDLEWARE === 'true'
|
||||
|
||||
if (bypassAuth) {
|
||||
return
|
||||
}
|
||||
|
||||
const session = SessionToken.get()
|
||||
if (!session?.token?.access_token) {
|
||||
throw redirect({ to: '/auth/login' })
|
||||
|
||||
@@ -1,65 +1,228 @@
|
||||
import { createFileRoute, Outlet, Link, useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { useAuthStore } from '@imphnen-frontend-service/service'
|
||||
import { Icon } from '@iconify/react'
|
||||
import { resolvePersona } from './dashboard/_data/persona-resolver'
|
||||
|
||||
const navItems = [
|
||||
{ path: '/dashboard', label: 'Dashboard', icon: 'mdi:view-dashboard' },
|
||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'mdi:cog' },
|
||||
]
|
||||
interface NavItem {
|
||||
path?: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
isLink?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get navigation items based on persona.
|
||||
* Only items with isLink=true will use Link component; others render as non-navigating buttons.
|
||||
*/
|
||||
function getNavItems(persona: 'user' | 'mentor'): NavItem[] {
|
||||
if (persona === 'mentor') {
|
||||
return [
|
||||
{ path: '/dashboard', label: 'Dashboard', icon: 'mdi:view-dashboard-outline', isLink: true },
|
||||
{ path: '/dashboard/mentoring-setup', label: 'Mentoring Setup', icon: 'mdi:cog-outline', isLink: false },
|
||||
{ path: '/dashboard/list-mentee', label: 'List Mentee', icon: 'mdi:account-group-outline', isLink: false },
|
||||
{ path: '/dashboard/feedback', label: 'Feedback', icon: 'mdi:message-reply-text-outline', isLink: false },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ path: '/dashboard', label: 'Dashboard', icon: 'mdi:view-dashboard-outline', isLink: true },
|
||||
{ path: '/dashboard/roadmap-discovery', label: 'Roadmap Discovery', icon: 'mdi:map-marker-path', isLink: true },
|
||||
{ path: '/dashboard/learning-path', label: 'Learning Path', icon: 'mdi:book-open-page-variant-outline', isLink: true },
|
||||
{ path: '/dashboard/mentoring', label: 'Mentoring', icon: 'mdi:video-outline', isLink: true },
|
||||
];
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/dashboard')({
|
||||
component: DashboardLayout,
|
||||
})
|
||||
|
||||
/**
|
||||
* Header component for the dashboard, containing the app brand and user profile.
|
||||
*/
|
||||
function HeaderDashboard({ persona, user, onLogout }: { persona: 'user' | 'mentor', user: any, onLogout: () => void }) {
|
||||
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false)
|
||||
|
||||
const notifications = [
|
||||
{ id: 1, title: 'Mentoring Sesi Baru', message: 'Kamu punya sesi mentoring besok jam 20:00 WIB', time: '2 jam yang lalu', unread: true },
|
||||
{ id: 2, title: 'Artikel Disetujui', message: 'Artikel "How to install linux" kamu telah disetujui mentor', time: '5 jam yang lalu', unread: false },
|
||||
{ id: 3, title: 'Roadmap Selesai', message: 'Selamat! Kamu telah menyelesaikan roadmap Front-end Basic', time: '1 hari yang lalu', unread: false },
|
||||
]
|
||||
|
||||
return (
|
||||
<header className="h-[58px] w-[972px] mx-auto mt-[52px] mb-[62px] bg-white rounded-sm shadow-sm flex items-center justify-between px-5 relative">
|
||||
<Link to="/dashboard" className="text-[19px] font-semibold text-primary-accent">
|
||||
Dimentorin.dev
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsNotificationsOpen(!isNotificationsOpen)}
|
||||
className={`w-7 h-7 rounded-sm flex items-center justify-center cursor-pointer transition-colors ${
|
||||
isNotificationsOpen ? 'bg-primary-50 text-primary-accent' : 'bg-white text-neutral-600'
|
||||
}`}
|
||||
>
|
||||
<Icon icon="lucide:bell" width="16" />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full border-2 border-white" />
|
||||
</button>
|
||||
|
||||
{isNotificationsOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-[320px] bg-white rounded-xl shadow-2xl border border-gray-100 z-50 overflow-hidden animate-in fade-in slide-in-from-top-2">
|
||||
<div className="p-4 border-b border-gray-50 flex items-center justify-between">
|
||||
<h3 className="font-bold text-gray-900">Notifikasi</h3>
|
||||
<button className="text-xs text-primary-600 font-medium hover:underline cursor-pointer">Tandai semua dibaca</button>
|
||||
</div>
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{notifications.map((n) => (
|
||||
<div key={n.id} className={`p-4 border-b border-gray-50 last:border-0 hover:bg-gray-50 transition-colors cursor-pointer ${n.unread ? 'bg-primary-50/30' : ''}`}>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<p className={`text-sm font-bold ${n.unread ? 'text-gray-900' : 'text-gray-700'}`}>{n.title}</p>
|
||||
<span className="text-[10px] text-gray-400 whitespace-nowrap">{n.time}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 line-clamp-2">{n.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 text-center">
|
||||
<button className="text-xs font-bold text-gray-600 hover:text-primary-600 transition-colors cursor-pointer">Lihat Semua Notifikasi</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to="/dashboard/settings"
|
||||
className="w-7 h-7 rounded-sm bg-white text-neutral-600 flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<Icon icon="lucide:settings" width="16" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/profile"
|
||||
className="h-[42px] flex items-center gap-3 pl-2.5 cursor-pointer border-none bg-transparent"
|
||||
>
|
||||
<div className="flex flex-col items-end text-right">
|
||||
<span className="text-xs font-medium text-neutral-600">{user?.fullname || 'User'}</span>
|
||||
<span className="text-[10px] font-medium text-neutral-600">{persona === 'mentor' ? 'Mentor' : 'Mentee'}</span>
|
||||
</div>
|
||||
<div
|
||||
className="w-7 h-7 rounded-full bg-bg-placeholder bg-cover bg-center"
|
||||
style={user?.avatar ? { backgroundImage: `url(${user.avatar})` } : {}}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard layout shell with persona-aware sidebar navigation.
|
||||
* Renders navigation and outlet for nested routes.
|
||||
*/
|
||||
function DashboardLayout() {
|
||||
const { session, clearSession } = useAuthStore()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const persona = resolvePersona(session?.user, searchParams);
|
||||
const navItems = getNavItems(persona);
|
||||
|
||||
const handleLogout = () => {
|
||||
clearSession();
|
||||
navigate({ to: '/auth/login' });
|
||||
};
|
||||
|
||||
const isNavItemActive = (path?: string) => {
|
||||
if (!path) return false;
|
||||
|
||||
// Dashboard should only be active on the dashboard index route.
|
||||
if (path === '/dashboard') {
|
||||
return location.pathname === '/dashboard' || location.pathname === '/dashboard/';
|
||||
}
|
||||
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<nav className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center gap-6">
|
||||
<Link to="/" className="text-xl font-bold text-primary-600 dark:text-primary-400">
|
||||
Dimentorin
|
||||
</Link>
|
||||
<div className="hidden md:flex gap-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-colors ${
|
||||
location.pathname === item.path
|
||||
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<Icon icon={item.icon} width="18" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:block">
|
||||
{session?.user?.fullname || session?.user?.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { clearSession(); navigate({ to: '/auth/login' }) }}
|
||||
className="text-sm text-red-500 hover:text-red-600 cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<Icon icon="mdi:logout" width="18" />
|
||||
<span className="hidden sm:inline">Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-screen bg-bg-light-blue flex">
|
||||
{/* Sidebar Navigation */}
|
||||
<aside className="w-[228px] bg-white flex flex-col sticky top-0 h-screen z-100">
|
||||
{/* Logo */}
|
||||
<div className="pt-[60px] px-6 pb-8">
|
||||
<div className="h-12 flex items-center justify-center">
|
||||
<img
|
||||
src="/logos/logo.svg"
|
||||
alt="Dimentorin"
|
||||
style={{ width: '128px', height: '48px', objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Navigation Items */}
|
||||
<nav className="flex-1 px-6 flex flex-col gap-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive = item.isLink ? isNavItemActive(item.path) : false;
|
||||
const baseClasses = 'h-8 px-3 rounded-sm flex items-center gap-3 cursor-pointer text-xs font-medium leading-[1.3] text-text-muted transition-all duration-200 hover:bg-bg-hover';
|
||||
const activeClasses = isActive ? 'bg-primary-accent text-white' : '';
|
||||
|
||||
if (item.isLink && item.path) {
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`${baseClasses} ${activeClasses}`}
|
||||
>
|
||||
<Icon
|
||||
icon={item.icon}
|
||||
width="16"
|
||||
className={isActive ? 'text-white' : 'text-text-muted'}
|
||||
style={isActive ? { color: '#ffffff' } : {}}
|
||||
/>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
className={baseClasses}
|
||||
type="button"
|
||||
disabled
|
||||
>
|
||||
<Icon icon={item.icon} width="16" />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Sidebar Footer */}
|
||||
<div className="flex flex-col pt-4 px-6 pb-[34px]">
|
||||
<div className="h-px bg-border-light mb-4" />
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="h-8 px-3 rounded-sm flex items-center gap-3 cursor-pointer text-xs font-medium leading-[1.3] text-text-muted transition-all duration-200 hover:bg-bg-hover"
|
||||
>
|
||||
<Icon icon="mdi:logout" width="16" />
|
||||
Log Out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<HeaderDashboard
|
||||
persona={persona}
|
||||
user={session?.user}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
|
||||
<main className="w-[1052px] mx-auto px-10 pt-6 pb-10">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { FC } from 'react'
|
||||
import { Icon } from '@iconify/react'
|
||||
|
||||
interface ArticlePreviewModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
article: {
|
||||
judul: string
|
||||
materi: string
|
||||
status: string
|
||||
submitDate: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export const ArticlePreviewModal: FC<ArticlePreviewModalProps> = ({ isOpen, onClose, article }) => {
|
||||
if (!isOpen || !article) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col relative">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer z-10"
|
||||
>
|
||||
<Icon icon="mdi:close" width="24" />
|
||||
</button>
|
||||
|
||||
<div className="p-8 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="flex items-center gap-2 text-primary-600 font-semibold text-sm mb-2 uppercase tracking-wider">
|
||||
<Icon icon="mdi:book-open-page-variant" width="18" />
|
||||
Article Preview
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white">{article.judul}</h2>
|
||||
<div className="flex items-center gap-4 mt-4 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon icon="mdi:calendar-clock" width="16" />
|
||||
{article.submitDate || 'Not submitted yet'}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon icon="mdi:tag-outline" width="16" />
|
||||
{article.materi}
|
||||
</div>
|
||||
<div className={`px-2 py-0.5 rounded-full text-xs font-bold ${
|
||||
article.status === 'Done' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{article.status}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-8 space-y-6">
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
<h3 className="text-xl font-bold mb-4">Introduction</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
||||
</p>
|
||||
|
||||
<div className="my-8 aspect-video bg-gray-100 dark:bg-gray-800 rounded-xl flex items-center justify-center overflow-hidden">
|
||||
<img src="/image/code-article.webp" alt="Article Cover" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold mb-4">Key Concepts</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
</p>
|
||||
|
||||
<ul className="list-disc pl-5 space-y-2 text-gray-600 dark:text-gray-400">
|
||||
<li>First major point of discussion</li>
|
||||
<li>Second key takeaway for readers</li>
|
||||
<li>Important consideration for implementation</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-bold mb-4 mt-8">Conclusion</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
In conclusion, following these steps will help you achieve the best results. Keep practicing and exploring more advanced topics to further your knowledge.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-800/50 flex justify-end gap-3 border-t border-gray-100 dark:border-gray-800">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-medium hover:bg-white dark:hover:bg-gray-800 transition-colors cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
{article.status !== 'Done' && (
|
||||
<button
|
||||
className="px-6 py-2 bg-primary-600 text-white rounded-xl font-medium hover:bg-primary-700 transition-colors cursor-pointer"
|
||||
>
|
||||
Edit Article
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import { FC } from 'react'
|
||||
import { Icon } from '@iconify/react'
|
||||
|
||||
interface MentorContactModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
mentor: {
|
||||
name: string
|
||||
topics: string[]
|
||||
} | null
|
||||
}
|
||||
|
||||
export const MentorContactModal: FC<MentorContactModalProps> = ({ isOpen, onClose, mentor }) => {
|
||||
if (!isOpen || !mentor) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-md p-8 relative">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer"
|
||||
>
|
||||
<Icon icon="mdi:close" width="24" />
|
||||
</button>
|
||||
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-20 h-20 bg-bg-placeholder rounded-full mx-auto mb-4 overflow-hidden">
|
||||
<img src="/image/mascot-character.webp" alt="Mentor" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-1">{mentor.name}</h3>
|
||||
<div className="flex flex-wrap justify-center gap-1 mt-2">
|
||||
{mentor.topics.map((t, i) => (
|
||||
<span key={i} className="px-2 py-0.5 bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400 text-[10px] font-bold rounded-full">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Hubungi mentor melalui platform berikut:</p>
|
||||
|
||||
<a
|
||||
href="https://wa.me/628123456789"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between p-4 bg-green-50 dark:bg-green-900/20 border border-green-100 dark:border-green-800 rounded-xl hover:bg-green-100 transition-colors cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon icon="mdi:whatsapp" width="24" className="text-green-600" />
|
||||
<div className="text-left">
|
||||
<p className="font-bold text-green-900 dark:text-green-400">WhatsApp</p>
|
||||
<p className="text-xs text-green-700 dark:text-green-500">Respon cepat (24 Jam)</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon="mdi:chevron-right" width="20" className="text-green-400 group-hover:translate-x-1 transition-transform" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="mailto:mentor@dimentorin.dev"
|
||||
className="flex items-center justify-between p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-xl hover:bg-blue-100 transition-colors cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon icon="mdi:email-outline" width="24" className="text-blue-600" />
|
||||
<div className="text-left">
|
||||
<p className="font-bold text-blue-900 dark:text-blue-400">Email</p>
|
||||
<p className="text-xs text-blue-700 dark:text-blue-500">Respon dalam 1-2 hari kerja</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon="mdi:chevron-right" width="20" className="text-blue-400 group-hover:translate-x-1 transition-transform" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://discord.gg/dimentorin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between p-4 bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-100 dark:border-indigo-800 rounded-xl hover:bg-indigo-100 transition-colors cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon icon="mdi:discord" width="24" className="text-indigo-600" />
|
||||
<div className="text-left">
|
||||
<p className="font-bold text-indigo-900 dark:text-indigo-400">Discord</p>
|
||||
<p className="text-xs text-indigo-700 dark:text-indigo-500">Komunitas & Sesi Live</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon="mdi:chevron-right" width="20" className="text-indigo-400 group-hover:translate-x-1 transition-transform" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full mt-8 py-3 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-xl font-semibold hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer"
|
||||
>
|
||||
Tutup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
import { FC, useState } from 'react'
|
||||
import { Icon } from '@iconify/react'
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
||||
|
||||
interface MentoringDetailModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onContactMentor: () => void
|
||||
mentor: {
|
||||
name: string
|
||||
title: string
|
||||
topics: string[]
|
||||
image: string
|
||||
}
|
||||
session: {
|
||||
date: string
|
||||
time: string
|
||||
location: string
|
||||
link?: string
|
||||
}
|
||||
}
|
||||
|
||||
export const MentoringDetailModal: FC<MentoringDetailModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onContactMentor,
|
||||
mentor,
|
||||
session,
|
||||
}) => {
|
||||
const [pertanyaan, setPertanyaan] = useState('')
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl overflow-hidden flex flex-col relative p-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-2xl font-bold text-primary-600">Detail Sesi Mentoring</h2>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<Icon icon="mdi:close" width="24" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-[280px_1fr] gap-12">
|
||||
{/* Left Column: Mentor Card */}
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="text-lg font-bold text-primary-600 mb-6 w-full text-center">Your Senpai</h3>
|
||||
<div className="flex flex-col items-center text-center w-full bg-gray-50 rounded-xl p-6">
|
||||
<div className="w-40 h-48 mb-6 overflow-hidden rounded-2xl bg-white flex items-center justify-center shadow-sm">
|
||||
<img
|
||||
src={mentor.image || '/image/mascot-character.webp'}
|
||||
alt={mentor.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-gray-900 leading-tight mb-1">
|
||||
{mentor.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{mentor.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Session Details */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Topics */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-3">Topics</label>
|
||||
<div className="flex flex-wrap gap-2 bg-gray-50 p-4 rounded-lg">
|
||||
{mentor.topics.map((topic, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-3 py-1.5 rounded-lg bg-white border border-gray-200 text-xs font-semibold text-gray-700 inline-flex items-center"
|
||||
>
|
||||
▪ {topic}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date and Time Row */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">Tanggal</label>
|
||||
<div className="w-full px-4 py-2.5 rounded-lg border border-gray-300 bg-white text-sm text-gray-700 font-medium">
|
||||
{session.date}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">Waktu</label>
|
||||
<div className="w-full px-4 py-2.5 rounded-lg border border-gray-300 bg-white text-sm text-gray-700 font-medium">
|
||||
{session.time}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">Lokasi</label>
|
||||
<div className="w-full px-4 py-2.5 rounded-lg border border-gray-300 bg-white text-sm text-gray-700 font-medium">
|
||||
{session.location}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pertanyaan Untuk Senpai */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">Pertanyaan Untuk Senpai</label>
|
||||
<textarea
|
||||
value={pertanyaan}
|
||||
onChange={(e) => setPertanyaan(e.target.value)}
|
||||
placeholder="Hi [Nama Mentor], Saya [Nama Kamu] & saya berharap dapat memiliki sesi mentoring dengan Anda.
|
||||
|
||||
Saat ini, saya tertarik untuk mengejar _____. Tujuan saya untuk sesi ini adalah _____.
|
||||
|
||||
Saya ingin tahu secara khusus tentang _____:
|
||||
1. Pertanyaan Anda
|
||||
2. ...
|
||||
3. ..."
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 bg-white text-sm text-gray-700 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-600 focus:border-transparent resize-none"
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Contact Button */}
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={onContactMentor}
|
||||
>
|
||||
<Icon icon="mdi:phone-outline" width="18" />
|
||||
Hubungi Senpai
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import { FC, useState } from 'react'
|
||||
import { Icon } from '@iconify/react'
|
||||
|
||||
interface MentoringFeedbackModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
mentorName: string
|
||||
}
|
||||
|
||||
export const MentoringFeedbackModal: FC<MentoringFeedbackModalProps> = ({ isOpen, onClose, mentorName }) => {
|
||||
const [step, setStep] = useState(1)
|
||||
const [rating, setRating] = useState(0)
|
||||
const [hoveredRating, setHoveredRating] = useState(0)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleNext = () => setStep(2)
|
||||
const handleBack = () => setStep(1)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-[16px] shadow-2xl w-full max-w-[440px] overflow-hidden flex flex-col relative p-8">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-6 right-6 text-[#888888] hover:text-[#454545] cursor-pointer z-10"
|
||||
>
|
||||
<Icon icon="mdi:close" width="24" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h2 className="text-[23px] font-bold text-[#23A1EB] mb-2">Beri Feedback</h2>
|
||||
<p className="text-[15px] font-medium text-[#888888] mb-8">
|
||||
Bagaimana sesi mentoring kamu bersama {mentorName}?
|
||||
</p>
|
||||
|
||||
{step === 1 ? (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-center gap-2 mb-10">
|
||||
{[1, 2, 3, 4, 5].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onMouseEnter={() => setHoveredRating(s)}
|
||||
onMouseLeave={() => setHoveredRating(0)}
|
||||
onClick={() => setRating(s)}
|
||||
className="cursor-pointer transition-transform hover:scale-110"
|
||||
>
|
||||
<Icon
|
||||
icon={s <= (hoveredRating || rating) ? 'mdi:star' : 'mdi:star-outline'}
|
||||
width="48"
|
||||
className={s <= (hoveredRating || rating) ? 'text-[#FFB810]' : 'text-[#D1D1D1]'}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 h-[43px] rounded-lg border border-[#D1D1D1] text-[#6D6D6D] font-bold text-[15px] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={rating === 0}
|
||||
className="flex-1 h-[43px] rounded-lg bg-[#23A1EB] text-white font-bold text-[15px] hover:bg-[#1e88c7] transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Lanjut
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<textarea
|
||||
placeholder="Tulis pesan untuk mentor kamu..."
|
||||
className="w-full h-[160px] p-5 border border-[#D1D1D1] rounded-lg mb-8 resize-none outline-none focus:border-[#23A1EB] text-[15px] text-[#454545] placeholder:text-[#BBBBBB] font-medium"
|
||||
/>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex-1 h-[43px] rounded-lg border border-[#D1D1D1] text-[#6D6D6D] font-bold text-[15px] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
Kembali
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 h-[43px] rounded-lg bg-[#23A1EB] text-white font-bold text-[15px] hover:bg-[#1e88c7] transition-colors cursor-pointer"
|
||||
>
|
||||
Kirim
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getUserMockDashboardData,
|
||||
getMentorMockDashboardData,
|
||||
mockUserDashboardData,
|
||||
mockMentorDashboardData,
|
||||
} from './dashboard-mock';
|
||||
import { resolvePersona, parseSearchParams } from '../persona-resolver';
|
||||
import type { TUserItem } from '@imphnen-frontend-service/service';
|
||||
|
||||
describe('Dashboard Mock Data', () => {
|
||||
describe('getUserMockDashboardData', () => {
|
||||
it('should return user dashboard data with correct structure', () => {
|
||||
const data = getUserMockDashboardData();
|
||||
|
||||
expect(data).toHaveProperty('mentoringSessions');
|
||||
expect(data).toHaveProperty('articleSubmitted');
|
||||
expect(data).toHaveProperty('articlePublished');
|
||||
expect(data).toHaveProperty('roadmap');
|
||||
expect(data).toHaveProperty('articles');
|
||||
});
|
||||
|
||||
it('should include numeric metrics', () => {
|
||||
const data = getUserMockDashboardData();
|
||||
|
||||
expect(typeof data.mentoringSessions).toBe('number');
|
||||
expect(typeof data.articleSubmitted).toBe('number');
|
||||
expect(typeof data.articlePublished).toBe('number');
|
||||
});
|
||||
|
||||
it('should include roadmap items with required fields', () => {
|
||||
const data = getUserMockDashboardData();
|
||||
|
||||
expect(data.roadmap.length).toBeGreaterThan(0);
|
||||
data.roadmap.forEach((item) => {
|
||||
expect(item).toHaveProperty('id');
|
||||
expect(item).toHaveProperty('name');
|
||||
expect(item).toHaveProperty('completionPercentage');
|
||||
expect(item).toHaveProperty('durationDays');
|
||||
expect(typeof item.completionPercentage).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
it('should include articles with required fields', () => {
|
||||
const data = getUserMockDashboardData();
|
||||
|
||||
expect(data.articles.length).toBeGreaterThan(0);
|
||||
data.articles.forEach((article) => {
|
||||
expect(article).toHaveProperty('id');
|
||||
expect(article).toHaveProperty('no');
|
||||
expect(article).toHaveProperty('judul');
|
||||
expect(article).toHaveProperty('materi');
|
||||
expect(article).toHaveProperty('status');
|
||||
expect(article).toHaveProperty('submitDate');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have sample row matching spec', () => {
|
||||
const data = getUserMockDashboardData();
|
||||
const firstArticle = data.articles[0];
|
||||
|
||||
expect(firstArticle.judul).toContain('How to install linux dist');
|
||||
expect(firstArticle.materi).toBe('Day 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMentorMockDashboardData', () => {
|
||||
it('should return mentor dashboard data with correct structure', () => {
|
||||
const data = getMentorMockDashboardData();
|
||||
|
||||
expect(data).toHaveProperty('rating');
|
||||
expect(data).toHaveProperty('sessionComplete');
|
||||
expect(data).toHaveProperty('menteeImpacted');
|
||||
expect(data).toHaveProperty('totalFeedback');
|
||||
expect(data).toHaveProperty('topics');
|
||||
expect(data).toHaveProperty('mentoringSetup');
|
||||
expect(data).toHaveProperty('payments');
|
||||
});
|
||||
|
||||
it('should include numeric metrics in valid ranges', () => {
|
||||
const data = getMentorMockDashboardData();
|
||||
|
||||
expect(data.rating).toBeGreaterThanOrEqual(0);
|
||||
expect(data.rating).toBeLessThanOrEqual(5);
|
||||
expect(data.sessionComplete).toBeGreaterThanOrEqual(0);
|
||||
expect(data.menteeImpacted).toBeGreaterThanOrEqual(0);
|
||||
expect(data.totalFeedback).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should include topic chips', () => {
|
||||
const data = getMentorMockDashboardData();
|
||||
|
||||
expect(data.topics.length).toBeGreaterThan(0);
|
||||
data.topics.forEach((topic) => {
|
||||
expect(topic).toHaveProperty('id');
|
||||
expect(topic).toHaveProperty('label');
|
||||
expect(typeof topic.label).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
it('should include mentoring setup config', () => {
|
||||
const data = getMentorMockDashboardData();
|
||||
const setup = data.mentoringSetup;
|
||||
|
||||
expect(setup).toHaveProperty('sessionRate');
|
||||
expect(setup).toHaveProperty('availability');
|
||||
expect(setup).toHaveProperty('expertise');
|
||||
expect(setup).toHaveProperty('experienceLevel');
|
||||
expect(setup).toHaveProperty('status');
|
||||
expect(['Incomplete', 'Complete']).toContain(setup.status);
|
||||
});
|
||||
|
||||
it('should include payments with required fields', () => {
|
||||
const data = getMentorMockDashboardData();
|
||||
|
||||
expect(data.payments.length).toBeGreaterThan(0);
|
||||
data.payments.forEach((payment) => {
|
||||
expect(payment).toHaveProperty('id');
|
||||
expect(payment).toHaveProperty('no');
|
||||
expect(payment).toHaveProperty('tanggalMentoring');
|
||||
expect(payment).toHaveProperty('sesi');
|
||||
expect(payment).toHaveProperty('namaMentee');
|
||||
expect(payment).toHaveProperty('jumlah');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have sample payment row matching spec', () => {
|
||||
const data = getMentorMockDashboardData();
|
||||
const firstPayment = data.payments[0];
|
||||
|
||||
expect(firstPayment.no).toBe(1);
|
||||
expect(firstPayment.tanggalMentoring).toBe('28-01-2025');
|
||||
expect(firstPayment.sesi).toBe('Senin, 19:00 - 19:45');
|
||||
expect(firstPayment.namaMentee).toBe('Firdaus Wijaya');
|
||||
expect(firstPayment.jumlah).toBe('Rp.100.000');
|
||||
});
|
||||
|
||||
it('should sort payments by date descending (newest first)', () => {
|
||||
const data = getMentorMockDashboardData();
|
||||
|
||||
for (let i = 0; i < data.payments.length - 1; i++) {
|
||||
const current = new Date(data.payments[i].tanggalMentoring.split('-').reverse().join('-'));
|
||||
const next = new Date(data.payments[i + 1].tanggalMentoring.split('-').reverse().join('-'));
|
||||
expect(current.getTime()).toBeGreaterThanOrEqual(next.getTime());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mock data completeness', () => {
|
||||
it('user dashboard should have consistent data', () => {
|
||||
const data = mockUserDashboardData;
|
||||
expect(data.articles.length).toBeGreaterThan(0);
|
||||
expect(data.roadmap.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('mentor dashboard should have consistent data', () => {
|
||||
const data = mockMentorDashboardData;
|
||||
expect(data.payments.length).toBeGreaterThan(0);
|
||||
expect(data.topics.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Persona Resolver', () => {
|
||||
describe('resolvePersona', () => {
|
||||
it('should respect query parameter override to mentor', () => {
|
||||
const user: TUserItem = {
|
||||
id: '1',
|
||||
email: 'user@example.com',
|
||||
fullname: 'Test User',
|
||||
is_active: true,
|
||||
role: { id: 'role-1', name: 'user', permissions: [] },
|
||||
};
|
||||
const searchParams = new URLSearchParams('persona=mentor');
|
||||
|
||||
const persona = resolvePersona(user, searchParams);
|
||||
expect(persona).toBe('mentor');
|
||||
});
|
||||
|
||||
it('should respect query parameter override to user', () => {
|
||||
const user: TUserItem = {
|
||||
id: '1',
|
||||
email: 'mentor@example.com',
|
||||
fullname: 'Test Mentor',
|
||||
is_active: true,
|
||||
role: { id: 'role-2', name: 'mentor', permissions: [] },
|
||||
};
|
||||
const searchParams = new URLSearchParams('persona=user');
|
||||
|
||||
const persona = resolvePersona(user, searchParams);
|
||||
expect(persona).toBe('user');
|
||||
});
|
||||
|
||||
it('should derive mentor from role name', () => {
|
||||
const user: TUserItem = {
|
||||
id: '1',
|
||||
email: 'mentor@example.com',
|
||||
fullname: 'Test Mentor',
|
||||
is_active: true,
|
||||
role: { id: 'role-2', name: 'mentor', permissions: [] },
|
||||
};
|
||||
|
||||
const persona = resolvePersona(user);
|
||||
expect(persona).toBe('mentor');
|
||||
});
|
||||
|
||||
it('should handle case-insensitive role name', () => {
|
||||
const user: TUserItem = {
|
||||
id: '1',
|
||||
email: 'mentor@example.com',
|
||||
fullname: 'Test Mentor',
|
||||
is_active: true,
|
||||
role: { id: 'role-2', name: 'MENTOR', permissions: [] },
|
||||
};
|
||||
|
||||
const persona = resolvePersona(user);
|
||||
expect(persona).toBe('mentor');
|
||||
});
|
||||
|
||||
it('should return user for non-mentor role', () => {
|
||||
const user: TUserItem = {
|
||||
id: '1',
|
||||
email: 'user@example.com',
|
||||
fullname: 'Test User',
|
||||
is_active: true,
|
||||
role: { id: 'role-1', name: 'user', permissions: [] },
|
||||
};
|
||||
|
||||
const persona = resolvePersona(user);
|
||||
expect(persona).toBe('user');
|
||||
});
|
||||
|
||||
it('should default to user when user is undefined', () => {
|
||||
const persona = resolvePersona(undefined);
|
||||
expect(persona).toBe('user');
|
||||
});
|
||||
|
||||
it('should ignore invalid query parameters', () => {
|
||||
const user: TUserItem = {
|
||||
id: '1',
|
||||
email: 'user@example.com',
|
||||
fullname: 'Test User',
|
||||
is_active: true,
|
||||
role: { id: 'role-1', name: 'user', permissions: [] },
|
||||
};
|
||||
const searchParams = new URLSearchParams('persona=invalid');
|
||||
|
||||
const persona = resolvePersona(user, searchParams);
|
||||
expect(persona).toBe('user');
|
||||
});
|
||||
|
||||
it('should ignore missing query parameter', () => {
|
||||
const user: TUserItem = {
|
||||
id: '1',
|
||||
email: 'user@example.com',
|
||||
fullname: 'Test User',
|
||||
is_active: true,
|
||||
role: { id: 'role-1', name: 'user', permissions: [] },
|
||||
};
|
||||
const searchParams = new URLSearchParams('other=value');
|
||||
|
||||
const persona = resolvePersona(user, searchParams);
|
||||
expect(persona).toBe('user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSearchParams', () => {
|
||||
it('should parse query string correctly', () => {
|
||||
const result = parseSearchParams('?persona=mentor');
|
||||
expect(result.get('persona')).toBe('mentor');
|
||||
});
|
||||
|
||||
it('should handle multiple parameters', () => {
|
||||
const result = parseSearchParams('?persona=user&other=value');
|
||||
expect(result.get('persona')).toBe('user');
|
||||
expect(result.get('other')).toBe('value');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const result = parseSearchParams('');
|
||||
expect(result.get('persona')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import type {
|
||||
UserDashboardData,
|
||||
MentorDashboardData,
|
||||
MentorPaymentRecord,
|
||||
MentorTopicChip,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Mock data for user dashboard.
|
||||
* Used during development before API integration.
|
||||
*/
|
||||
export const mockUserDashboardData: UserDashboardData = {
|
||||
mentoringSessions: 0,
|
||||
articleSubmitted: 0,
|
||||
articlePublished: 0,
|
||||
roadmap: [
|
||||
{
|
||||
id: 'roadmap-1',
|
||||
name: 'Front End Basic',
|
||||
completionPercentage: 50,
|
||||
durationDays: 30,
|
||||
},
|
||||
{
|
||||
id: 'roadmap-2',
|
||||
name: 'React Fundamentals',
|
||||
completionPercentage: 25,
|
||||
durationDays: 21,
|
||||
},
|
||||
{
|
||||
id: 'roadmap-3',
|
||||
name: 'TypeScript Essentials',
|
||||
completionPercentage: 10,
|
||||
durationDays: 14,
|
||||
},
|
||||
],
|
||||
articles: [
|
||||
{
|
||||
id: 'article-1',
|
||||
no: 1,
|
||||
judul: 'How to install linux dist',
|
||||
materi: 'Day 1',
|
||||
status: 'Published',
|
||||
submitDate: '2025-04-10',
|
||||
},
|
||||
{
|
||||
id: 'article-2',
|
||||
no: 2,
|
||||
judul: 'Mastering CSS Grid Layout',
|
||||
materi: 'Day 2',
|
||||
status: 'Published',
|
||||
submitDate: '2025-04-08',
|
||||
},
|
||||
{
|
||||
id: 'article-3',
|
||||
no: 3,
|
||||
judul: 'React Hooks Deep Dive',
|
||||
materi: 'Day 5',
|
||||
status: 'Submitted',
|
||||
submitDate: '2025-04-05',
|
||||
},
|
||||
{
|
||||
id: 'article-4',
|
||||
no: 4,
|
||||
judul: 'Understanding Async/Await',
|
||||
materi: 'Day 3',
|
||||
status: 'Draft',
|
||||
submitDate: '2025-04-03',
|
||||
},
|
||||
{
|
||||
id: 'article-5',
|
||||
no: 5,
|
||||
judul: 'TypeScript Advanced Types',
|
||||
materi: 'Day 7',
|
||||
status: 'Rejected',
|
||||
submitDate: '2025-04-01',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock mentor topics for chips display.
|
||||
*/
|
||||
const mockMentorTopics: MentorTopicChip[] = [
|
||||
{ id: 'topic-1', label: 'Basic IT' },
|
||||
{ id: 'topic-2', label: 'Career & Self Development' },
|
||||
{ id: 'topic-3', label: 'PM & IT Tools' },
|
||||
{ id: 'topic-4', label: 'Programming' },
|
||||
{ id: 'topic-5', label: 'Industry Insight' },
|
||||
{ id: 'topic-6', label: 'AI Tips' },
|
||||
{ id: 'topic-7', label: 'Data & Database' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Mock payments history for mentor dashboard.
|
||||
* Sorted by tanggalMentoring in descending order (newest first).
|
||||
*/
|
||||
const mockMentorPayments: MentorPaymentRecord[] = [
|
||||
{
|
||||
id: 'payment-1',
|
||||
no: 1,
|
||||
tanggalMentoring: '28-01-2025',
|
||||
sesi: 'Senin, 19:00 - 19:45',
|
||||
namaMentee: 'Firdaus Wijaya',
|
||||
jumlah: 'Rp.100.000',
|
||||
},
|
||||
{
|
||||
id: 'payment-2',
|
||||
no: 2,
|
||||
tanggalMentoring: '27-01-2025',
|
||||
sesi: 'Minggu, 14:00 - 14:45',
|
||||
namaMentee: 'Ahmad Rizki',
|
||||
jumlah: 'Rp.100.000',
|
||||
},
|
||||
{
|
||||
id: 'payment-3',
|
||||
no: 3,
|
||||
tanggalMentoring: '25-01-2025',
|
||||
sesi: 'Jumat, 19:00 - 19:45',
|
||||
namaMentee: 'Siti Nurhaliza',
|
||||
jumlah: 'Rp.150.000',
|
||||
},
|
||||
{
|
||||
id: 'payment-4',
|
||||
no: 4,
|
||||
tanggalMentoring: '24-01-2025',
|
||||
sesi: 'Kamis, 18:00 - 18:45',
|
||||
namaMentee: 'Budi Santoso',
|
||||
jumlah: 'Rp.100.000',
|
||||
},
|
||||
{
|
||||
id: 'payment-5',
|
||||
no: 5,
|
||||
tanggalMentoring: '22-01-2025',
|
||||
sesi: 'Selasa, 19:00 - 19:45',
|
||||
namaMentee: 'Eka Suryanto',
|
||||
jumlah: 'Rp.120.000',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Mock data for mentor dashboard.
|
||||
* Used during development before API integration.
|
||||
*/
|
||||
export const mockMentorDashboardData: MentorDashboardData = {
|
||||
rating: 0,
|
||||
sessionComplete: 0,
|
||||
menteeImpacted: 0,
|
||||
totalFeedback: 0,
|
||||
topics: mockMentorTopics,
|
||||
mentoringSetup: {
|
||||
sessionRate: 'Rp.100.000/sesi',
|
||||
availability: 'Senin-Jumat, 19:00-21:00',
|
||||
expertise: ['JavaScript', 'React', 'TypeScript', 'Backend', 'System Design'],
|
||||
experienceLevel: 'Senior',
|
||||
status: 'Complete',
|
||||
},
|
||||
payments: mockMentorPayments,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get mock user dashboard data.
|
||||
* Can be extended to support filtering/pagination.
|
||||
*/
|
||||
export function getUserMockDashboardData(): UserDashboardData {
|
||||
return mockUserDashboardData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mock mentor dashboard data.
|
||||
* Can be extended to support filtering/pagination.
|
||||
*/
|
||||
export function getMentorMockDashboardData(): MentorDashboardData {
|
||||
return mockMentorDashboardData;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/** User Dashboard Mock Data Types */
|
||||
export interface UserArticle {
|
||||
id: string;
|
||||
no: number;
|
||||
judul: string;
|
||||
materi: string;
|
||||
status: 'Draft' | 'Submitted' | 'Published' | 'Rejected';
|
||||
submitDate: string;
|
||||
}
|
||||
|
||||
export interface UserRoadmapItem {
|
||||
id: string;
|
||||
name: string;
|
||||
completionPercentage: number;
|
||||
durationDays: number;
|
||||
}
|
||||
|
||||
export interface UserDashboardData {
|
||||
mentoringSessions: number;
|
||||
articleSubmitted: number;
|
||||
articlePublished: number;
|
||||
roadmap: UserRoadmapItem[];
|
||||
articles: UserArticle[];
|
||||
}
|
||||
|
||||
/** Mentor Dashboard Mock Data Types */
|
||||
export interface MentorPaymentRecord {
|
||||
id: string;
|
||||
no: number;
|
||||
tanggalMentoring: string; // DD-MM-YYYY
|
||||
sesi: string; // Hari, HH:MM - HH:MM
|
||||
namaMentee: string;
|
||||
jumlah: string; // Rp.###.###
|
||||
}
|
||||
|
||||
export interface MentorMentoringSetup {
|
||||
sessionRate: string; // e.g., "Rp.100.000/sesi"
|
||||
availability: string; // e.g., "Senin-Jumat, 19:00-21:00"
|
||||
expertise: string[];
|
||||
experienceLevel: string; // e.g., "Senior"
|
||||
status: 'Incomplete' | 'Complete';
|
||||
}
|
||||
|
||||
export interface MentorTopicChip {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface MentorDashboardData {
|
||||
rating: number; // 0-5
|
||||
sessionComplete: number;
|
||||
menteeImpacted: number;
|
||||
totalFeedback: number;
|
||||
topics: MentorTopicChip[];
|
||||
mentoringSetup: MentorMentoringSetup;
|
||||
payments: MentorPaymentRecord[];
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { TUserItem } from '@imphnen-frontend-service/service';
|
||||
|
||||
/**
|
||||
* Persona type for dashboard rendering.
|
||||
*/
|
||||
export type Persona = 'user' | 'mentor';
|
||||
|
||||
/**
|
||||
* Resolves the persona for the dashboard based on authentication and query parameters.
|
||||
*
|
||||
* Resolution priority:
|
||||
* 1. Query parameter `?persona=user|mentor` (explicit override)
|
||||
* 2. User role name contains 'mentor' (case-insensitive)
|
||||
* 3. Fallback to 'user' persona
|
||||
*
|
||||
* @param user - The authenticated user object
|
||||
* @param searchParams - URL search parameters
|
||||
* @returns The resolved persona
|
||||
*/
|
||||
export function resolvePersona(
|
||||
user: TUserItem | undefined,
|
||||
searchParams?: URLSearchParams
|
||||
): Persona {
|
||||
// Check for explicit persona query parameter
|
||||
if (searchParams) {
|
||||
const param = searchParams.get('persona');
|
||||
if (param === 'mentor' || param === 'user') {
|
||||
return param;
|
||||
}
|
||||
}
|
||||
|
||||
// Derive from user role name
|
||||
if (user?.role?.name) {
|
||||
const roleName = user.role.name.toLocaleLowerCase();
|
||||
if (roleName.includes('mentor')) {
|
||||
return 'mentor';
|
||||
}
|
||||
}
|
||||
|
||||
// Default to user persona
|
||||
return 'user';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses search parameters from a URL search string.
|
||||
* Utility for testing and usage without URLSearchParams API.
|
||||
*
|
||||
* @param search - URL search string (e.g., "?persona=mentor")
|
||||
* @returns URLSearchParams instance
|
||||
*/
|
||||
export function parseSearchParams(search: string): URLSearchParams {
|
||||
return new URLSearchParams(search);
|
||||
}
|
||||
Loaded 50 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user