Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d3e2470c8 | ||
|
|
48411d3a08 | ||
|
|
426b71f341 | ||
|
|
06c69cb3c5 | ||
|
|
81999aa044 | ||
|
|
d83a6d9ec7 | ||
|
|
63dfb7299f | ||
|
|
53079fe868 | ||
|
|
2944a2b98a | ||
|
|
79f087e2fc |
No files matched your search
+8
-1
@@ -4,7 +4,7 @@
|
|||||||
dist
|
dist
|
||||||
tmp
|
tmp
|
||||||
out-tsc
|
out-tsc
|
||||||
|
docs/FigmaImage/
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
@@ -73,3 +73,10 @@ result
|
|||||||
|
|
||||||
.claude/worktrees
|
.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_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 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 'tailwindcss';
|
||||||
|
@import 'tw-animate-css';
|
||||||
@source "../../../libs/ui/**/*.{ts,tsx}";
|
@source "../../../libs/ui/**/*.{ts,tsx}";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
@@ -96,6 +97,45 @@
|
|||||||
/* ~10px */
|
/* ~10px */
|
||||||
--text-label3: 0.677rem;
|
--text-label3: 0.677rem;
|
||||||
/* ~8px */
|
/* ~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 {
|
@layer base {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { StrictMode } from 'react'
|
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||||
import { QueryProvider } from '@imphnen-frontend-service/utils'
|
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')
|
if (!rootElement) throw new Error('Failed to find the root element')
|
||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</StrictMode>
|
|
||||||
)
|
)
|
||||||
@@ -1,63 +1,28 @@
|
|||||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
|
||||||
import { SessionToken } from '@imphnen-frontend-service/service'
|
import { SessionToken } from '@imphnen-frontend-service/service';
|
||||||
import { useState } from 'react'
|
import {
|
||||||
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms'
|
SidebarInset,
|
||||||
|
SidebarProvider,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { BackofficeSidebar } from '../components/sidebar';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated')({
|
export const Route = createFileRoute('/_authenticated')({
|
||||||
beforeLoad: () => {
|
beforeLoad: () => {
|
||||||
const session = SessionToken.get()
|
const session = SessionToken.get();
|
||||||
if (!session?.token?.access_token) {
|
if (!session?.token?.access_token) {
|
||||||
throw redirect({ to: '/auth/login' })
|
throw redirect({ to: '/auth/login' });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
component: AuthenticatedLayout,
|
component: AuthenticatedLayout,
|
||||||
})
|
});
|
||||||
|
|
||||||
function AuthenticatedLayout() {
|
function AuthenticatedLayout() {
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
<SidebarProvider>
|
||||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
<BackofficeSidebar />
|
||||||
<BackofficeSidebar
|
<SidebarInset>
|
||||||
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 />
|
<Outlet />
|
||||||
</div>
|
</SidebarInset>
|
||||||
</div>
|
</SidebarProvider>
|
||||||
</div>
|
);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
+10
-5
@@ -1,5 +1,4 @@
|
|||||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"
|
||||||
import { PieLabelProps } from "recharts/types/polar/Pie"
|
|
||||||
|
|
||||||
type ChartProps = {
|
type ChartProps = {
|
||||||
name: string
|
name: string
|
||||||
@@ -14,14 +13,20 @@ const chartData: ChartProps[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const RADIAN = Math.PI / 180;
|
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 radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||||
const x = cx + radius * Math.cos(-(midAngle ?? 0) * RADIAN);
|
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||||
const y = cy + radius * Math.sin(-(midAngle ?? 0) * RADIAN);
|
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central">
|
<text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central">
|
||||||
{`${((percent ?? 1) * 100).toFixed(0)}%`}
|
{`${(percent * 100).toFixed(0)}%`}
|
||||||
</text>
|
</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 { cn } from "@imphnen-frontend-service/utils"
|
||||||
import { FC } from "react"
|
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 { cn } from "@imphnen-frontend-service/utils"
|
||||||
import { FC } from "react"
|
import { FC } from "react"
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import * as React from 'react'
|
import * as React from 'react';
|
||||||
import { Fragment, useState } from 'react'
|
import { Filter as FilterIcon, Search, Pencil } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
FilterOutlined,
|
Button,
|
||||||
SearchOutlined,
|
Card,
|
||||||
EditOutlined,
|
CardContent,
|
||||||
} from '@ant-design/icons'
|
CardHeader,
|
||||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
Checkbox,
|
||||||
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms'
|
Input,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
Badge,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
Filter,
|
||||||
|
BackofficeWrapper,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -15,107 +25,96 @@ import {
|
|||||||
PaginationState,
|
PaginationState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
useUserList,
|
useUserList,
|
||||||
TUsersListItem,
|
TUsersListItem,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/accounts')({
|
export const Route = createFileRoute('/_authenticated/accounts')({
|
||||||
component: AccountsPage,
|
component: AccountsPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function AccountsPage() {
|
function AccountsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = React.useState('');
|
||||||
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
const [showFilter, setShowFilter] = useState(false)
|
const [showFilter, setShowFilter] = React.useState(false);
|
||||||
|
|
||||||
const { data: usersData, isLoading } = useUserList({
|
const { data: usersData, isLoading } = useUserList({
|
||||||
search,
|
search,
|
||||||
page: pagination.pageIndex + 1,
|
page: pagination.pageIndex + 1,
|
||||||
per_page: pagination.pageSize,
|
per_page: pagination.pageSize,
|
||||||
})
|
});
|
||||||
|
|
||||||
const users: TUsersListItem[] = usersData?.data ?? []
|
const users: TUsersListItem[] = usersData?.data ?? [];
|
||||||
const totalItems = usersData?.meta?.total ?? users.length
|
const totalItems = usersData?.meta?.total ?? users.length;
|
||||||
|
|
||||||
const columns: ColumnDef<TUsersListItem>[] = [
|
const columns: ColumnDef<TUsersListItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
checked={
|
||||||
className="rounded"
|
table.getIsAllRowsSelected()
|
||||||
checked={table.getIsAllRowsSelected()}
|
? true
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
: table.getIsSomeRowsSelected()
|
||||||
|
? 'indeterminate'
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
|
||||||
|
}
|
||||||
|
aria-label="Select all"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={row.getIsSelected()}
|
checked={row.getIsSelected()}
|
||||||
onChange={row.getToggleSelectedHandler()}
|
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
||||||
|
aria-label="Select row"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{ header: 'No', accessorKey: 'id' },
|
||||||
header: 'No',
|
{ header: 'Nama Lengkap', accessorKey: 'fullname' },
|
||||||
accessorKey: 'id',
|
{ header: 'Email', accessorKey: 'email' },
|
||||||
},
|
{ header: 'Role', accessorKey: 'role' },
|
||||||
{
|
|
||||||
header: 'Nama Lengkap',
|
|
||||||
accessorKey: 'fullname',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Email',
|
|
||||||
accessorKey: 'email',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Role',
|
|
||||||
accessorKey: 'role',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
accessorKey: 'is_active',
|
accessorKey: 'is_active',
|
||||||
cell: ({ row }) => (
|
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'}
|
{row.original.is_active ? 'Aktif' : 'Tidak Aktif'}
|
||||||
</span>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
navigate({ to: '/accounts/$id', params: { id: row.original.id } })
|
navigate({ to: '/accounts/$id', params: { id: row.original.id } });
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<EditOutlined /> Edit
|
<Pencil className="size-3.5" /> Edit
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: users,
|
data: users,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -123,52 +122,65 @@ function AccountsPage() {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<BackofficeWrapper
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
title="Data Akun"
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
description="Kelola akun pengguna yang terdaftar"
|
||||||
<h1 className="text-p2 font-semibold">Data Akun</h1>
|
>
|
||||||
</header>
|
<Card>
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
<CardHeader>
|
||||||
<div className="flex justify-between items-center gap-8 mb-2">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="relative w-full">
|
<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
|
<Input
|
||||||
placeholder="Cari berdasarkan nama lengkap, email"
|
placeholder="Cari nama lengkap atau email…"
|
||||||
className="pl-12 w-full max-h-full"
|
className="pl-9"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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>
|
<Popover open={showFilter} onOpenChange={setShowFilter}>
|
||||||
<div className="relative">
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button variant="secondary" size="md">
|
||||||
variant="primary"
|
<FilterIcon className="size-4" />
|
||||||
size="md"
|
|
||||||
className="flex items-center gap-3"
|
|
||||||
disabled
|
|
||||||
onClick={() => setShowFilter(!showFilter)}
|
|
||||||
>
|
|
||||||
<FilterOutlined />
|
|
||||||
Filters
|
Filters
|
||||||
</Button>
|
</Button>
|
||||||
{showFilter && (
|
</PopoverTrigger>
|
||||||
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
|
<PopoverContent align="end" className="w-auto p-0">
|
||||||
<Filter onClose={() => setShowFilter(false)} options={[]} />
|
<Filter
|
||||||
</div>
|
onClose={() => setShowFilter(false)}
|
||||||
)}
|
options={[
|
||||||
</div>
|
{ id: 'all', value: 'all', label: 'Semua' },
|
||||||
|
{ id: 'active', value: 'active', label: 'Aktif' },
|
||||||
|
{ id: 'inactive', value: 'inactive', label: 'Tidak Aktif' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{isLoading ? (
|
{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>
|
</CardContent>
|
||||||
</main>
|
</Card>
|
||||||
</Fragment>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,110 +1,122 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { useEffect, useState } from 'react'
|
import * as React from 'react';
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner';
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
import { ArrowLeft } from 'lucide-react';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
import {
|
||||||
import { InputField } from '@imphnen-frontend-service/ui/molecules'
|
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 {
|
import {
|
||||||
useUserList,
|
useUserList,
|
||||||
useUpdateUserById,
|
useUpdateUserById,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/accounts_/$id')({
|
export const Route = createFileRoute('/_authenticated/accounts_/$id')({
|
||||||
component: AccountsEditPage,
|
component: AccountsEditPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function AccountsEditPage() {
|
function AccountsEditPage() {
|
||||||
const { id } = Route.useParams()
|
const { id } = Route.useParams();
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const updateUser = useUpdateUserById()
|
const updateUser = useUpdateUserById();
|
||||||
|
|
||||||
const { data: usersData, isLoading } = useUserList({ search: '', per_page: 100 })
|
const { data: usersData, isLoading } = useUserList({
|
||||||
const user = usersData?.data?.find((u) => u.id === id)
|
search: '',
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
const user = usersData?.data?.find((u) => u.id === id);
|
||||||
|
|
||||||
const [fullName, setFullName] = useState('')
|
const [fullName, setFullName] = React.useState('');
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = React.useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setFullName(user.fullname)
|
setFullName(user.fullname);
|
||||||
setEmail(user.email)
|
setEmail(user.email);
|
||||||
}
|
}
|
||||||
}, [user])
|
}, [user]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
await updateUser.mutateAsync({ id, data: { fullname: fullName, email } })
|
await updateUser.mutateAsync({
|
||||||
toast.success('Data akun berhasil diperbarui')
|
id,
|
||||||
navigate({ to: '/accounts' })
|
data: { fullname: fullName, email },
|
||||||
|
});
|
||||||
|
toast.success('Data akun berhasil diperbarui');
|
||||||
|
navigate({ to: '/accounts' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
toast.error('Data akun gagal diperbarui')
|
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 (
|
return (
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
<BackofficeWrapper title="Edit Data Akun">
|
||||||
<div className="max-w-2xl mx-auto w-full">
|
<div className="mx-auto w-full max-w-2xl">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<Button
|
||||||
<button
|
variant="text"
|
||||||
|
size="sm"
|
||||||
onClick={() => navigate({ to: '/accounts' })}
|
onClick={() => navigate({ to: '/accounts' })}
|
||||||
className="text-primary-500 hover:text-primary-600"
|
className="mb-4 -ml-2"
|
||||||
>
|
>
|
||||||
<ArrowLeftOutlined className="text-[20px]" />
|
<ArrowLeft className="size-4" />
|
||||||
</button>
|
Kembali
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Edit Data Akun</h1>
|
</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>
|
||||||
|
) : (
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<InputField
|
<InputField
|
||||||
label="Nama Lengkap"
|
label="Nama Lengkap"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Masukkan Nama Lengkap"
|
placeholder="Masukkan nama lengkap"
|
||||||
value={fullName}
|
value={fullName}
|
||||||
onChange={(e) => setFullName(e.target.value)}
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
size="lg"
|
size="md"
|
||||||
className="w-full"
|
|
||||||
/>
|
/>
|
||||||
<InputField
|
<InputField
|
||||||
label="Email"
|
label="Email"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Masukkan Email"
|
placeholder="Masukkan email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
size="lg"
|
size="md"
|
||||||
className="w-full"
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex gap-3 pt-4">
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="lg"
|
size="md"
|
||||||
className="w-full"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
>
|
|
||||||
Perbarui Data
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="bordered"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => navigate({ to: '/accounts' })}
|
onClick={() => navigate({ to: '/accounts' })}
|
||||||
>
|
>
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={updateUser.isPending || isLoading}
|
||||||
|
>
|
||||||
|
{updateUser.isPending ? 'Menyimpan…' : 'Perbarui Data'}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</BackofficeWrapper>
|
||||||
</div>
|
);
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,27 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { Fragment, useState } from 'react'
|
import * as React from 'react';
|
||||||
|
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
AlertDialog,
|
||||||
EditOutlined,
|
AlertDialogAction,
|
||||||
DeleteOutlined,
|
AlertDialogCancel,
|
||||||
PlusOutlined,
|
AlertDialogContent,
|
||||||
} from '@ant-design/icons'
|
AlertDialogDescription,
|
||||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
AlertDialogFooter,
|
||||||
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
|
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 {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -15,76 +29,77 @@ import {
|
|||||||
PaginationState,
|
PaginationState,
|
||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
useEventList,
|
useEventList,
|
||||||
useDeleteEvent,
|
useDeleteEvent,
|
||||||
TEventsListItem,
|
TEventsListItem,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
import React from 'react'
|
import { toast } from 'sonner';
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/cms-events')({
|
export const Route = createFileRoute('/_authenticated/cms-events')({
|
||||||
component: CmsEventsPage,
|
component: CmsEventsPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function CmsEventsPage() {
|
function CmsEventsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = React.useState('');
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
|
|
||||||
const { data: eventsData, isLoading } = useEventList({
|
const { data: eventsData, isLoading } = useEventList({
|
||||||
search,
|
search,
|
||||||
page: pagination.pageIndex + 1,
|
page: pagination.pageIndex + 1,
|
||||||
per_page: pagination.pageSize,
|
per_page: pagination.pageSize,
|
||||||
})
|
});
|
||||||
const deleteEvent = useDeleteEvent()
|
const deleteEvent = useDeleteEvent();
|
||||||
|
|
||||||
const events: TEventsListItem[] = eventsData?.data ?? []
|
const events: TEventsListItem[] = eventsData?.data ?? [];
|
||||||
const totalItems = eventsData?.meta?.total ?? events.length
|
const totalItems = eventsData?.meta?.total ?? events.length;
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteEvent.mutateAsync(id)
|
await deleteEvent.mutateAsync(id);
|
||||||
toast.success('Data event berhasil dihapus')
|
toast.success('Data event berhasil dihapus');
|
||||||
setDeleteId(null)
|
setDeleteId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
toast.error('Data event gagal dihapus')
|
toast.error('Data event gagal dihapus');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<TEventsListItem>[] = [
|
const columns: ColumnDef<TEventsListItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
checked={
|
||||||
className="rounded"
|
table.getIsAllRowsSelected()
|
||||||
checked={table.getIsAllRowsSelected()}
|
? true
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
: table.getIsSomeRowsSelected()
|
||||||
|
? 'indeterminate'
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
|
||||||
|
}
|
||||||
|
aria-label="Select all"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={row.getIsSelected()}
|
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',
|
header: 'Location',
|
||||||
accessorKey: 'location',
|
accessorKey: 'location',
|
||||||
@@ -112,81 +127,49 @@ function CmsEventsPage() {
|
|||||||
header: 'Online',
|
header: 'Online',
|
||||||
accessorKey: 'is_online',
|
accessorKey: 'is_online',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span
|
<Badge variant={row.original.is_online ? 'success' : 'secondary'}>
|
||||||
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'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{row.original.is_online ? 'Online' : 'Offline'}
|
{row.original.is_online ? 'Online' : 'Offline'}
|
||||||
</span>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex gap-[8px]">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
navigate({ to: '/cms-events/$id', params: { id: row.original.id } })
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<EditOutlined /> Update
|
|
||||||
</Button>
|
|
||||||
{deleteId === row.original.id ? (
|
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setDeleteId(null)
|
navigate({
|
||||||
|
to: '/cms-events/$id',
|
||||||
|
params: { id: row.original.id },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Batal
|
<Pencil className="size-3.5" />
|
||||||
|
Update
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setDeleteId(row.original.id)
|
setDeleteId(row.original.id);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<DeleteOutlined /> Delete
|
<Trash2 className="size-3.5" />
|
||||||
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: events,
|
data: events,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -194,53 +177,74 @@ function CmsEventsPage() {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<BackofficeWrapper title="CMS Events" description="Kelola event komunitas">
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
<Card>
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
<CardHeader>
|
||||||
<h1 className="text-p2 font-semibold">CMS Events</h1>
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
</header>
|
<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" />
|
||||||
<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">
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Cari berdasarkan nama event"
|
placeholder="Cari nama event…"
|
||||||
className="pl-12 w-full max-h-full"
|
className="pl-9"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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>
|
|
||||||
<div className="relative">
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
className="flex gap-3 text-nowrap"
|
|
||||||
onClick={() => navigate({ to: '/cms-events/create' })}
|
onClick={() => navigate({ to: '/cms-events/create' })}
|
||||||
|
size="md"
|
||||||
>
|
>
|
||||||
<PlusOutlined />
|
<Plus className="size-4" />
|
||||||
Tambah Event
|
Tambah Event
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{isLoading ? (
|
{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
|
<DataTable
|
||||||
data={events}
|
data={events}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
pageSize={9}
|
|
||||||
table={table}
|
table={table}
|
||||||
|
manualPagination
|
||||||
|
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||||
|
currentPage={pagination.pageIndex + 1}
|
||||||
|
onPageChange={(p) =>
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardContent>
|
||||||
</main>
|
</Card>
|
||||||
</Fragment>
|
|
||||||
)
|
<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 { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { Fragment, useState } from 'react'
|
import * as React from 'react';
|
||||||
|
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
AlertDialog,
|
||||||
EditOutlined,
|
AlertDialogAction,
|
||||||
DeleteOutlined,
|
AlertDialogCancel,
|
||||||
PlusOutlined,
|
AlertDialogContent,
|
||||||
} from '@ant-design/icons'
|
AlertDialogDescription,
|
||||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
AlertDialogFooter,
|
||||||
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Checkbox,
|
||||||
|
Input,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
BackofficeWrapper,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -15,86 +28,84 @@ import {
|
|||||||
PaginationState,
|
PaginationState,
|
||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
useTestimonialList,
|
useTestimonialList,
|
||||||
useDeleteTestimonial,
|
useDeleteTestimonial,
|
||||||
TTestimonialsListItem,
|
TTestimonialsListItem,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
import React from 'react'
|
import { toast } from 'sonner';
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/cms-testimonials')({
|
export const Route = createFileRoute('/_authenticated/cms-testimonials')({
|
||||||
component: CmsTestimonialsPage,
|
component: CmsTestimonialsPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function CmsTestimonialsPage() {
|
function CmsTestimonialsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = React.useState('');
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
|
|
||||||
const { data: testimonialsData, isLoading } = useTestimonialList({
|
const { data: testimonialsData, isLoading } = useTestimonialList({
|
||||||
search,
|
search,
|
||||||
page: pagination.pageIndex + 1,
|
page: pagination.pageIndex + 1,
|
||||||
per_page: pagination.pageSize,
|
per_page: pagination.pageSize,
|
||||||
})
|
});
|
||||||
const deleteTestimonial = useDeleteTestimonial()
|
const deleteTestimonial = useDeleteTestimonial();
|
||||||
|
|
||||||
const testimonials: TTestimonialsListItem[] = testimonialsData?.data ?? []
|
const testimonials: TTestimonialsListItem[] = testimonialsData?.data ?? [];
|
||||||
const totalItems = testimonialsData?.meta?.total ?? testimonials.length
|
const totalItems = testimonialsData?.meta?.total ?? testimonials.length;
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteTestimonial.mutateAsync(id)
|
await deleteTestimonial.mutateAsync(id);
|
||||||
toast.success('Data testimonial berhasil dihapus')
|
toast.success('Data testimonial berhasil dihapus');
|
||||||
setDeleteId(null)
|
setDeleteId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
toast.error('Data testimonial gagal dihapus')
|
toast.error('Data testimonial gagal dihapus');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<TTestimonialsListItem>[] = [
|
const columns: ColumnDef<TTestimonialsListItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
checked={
|
||||||
className="rounded"
|
table.getIsAllRowsSelected()
|
||||||
checked={table.getIsAllRowsSelected()}
|
? true
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
: table.getIsSomeRowsSelected()
|
||||||
|
? 'indeterminate'
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={row.getIsSelected()}
|
checked={row.getIsSelected()}
|
||||||
onChange={row.getToggleSelectedHandler()}
|
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{ header: 'User', accessorKey: 'user_fullname' },
|
||||||
header: 'User',
|
{ header: 'Role', accessorKey: 'role' },
|
||||||
accessorKey: 'user_fullname',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Role',
|
|
||||||
accessorKey: 'role',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
header: 'Content',
|
header: 'Content',
|
||||||
accessorKey: 'content',
|
accessorKey: 'content',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const content = row.original.content
|
const content = row.original.content;
|
||||||
return content.length > 80 ? `${content.substring(0, 80)}...` : content
|
return content.length > 80
|
||||||
|
? `${content.substring(0, 80)}…`
|
||||||
|
: content;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -110,67 +121,41 @@ function CmsTestimonialsPage() {
|
|||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex gap-[8px]">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
navigate({ to: '/cms-testimonials/$id', params: { id: row.original.id } })
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<EditOutlined /> Update
|
|
||||||
</Button>
|
|
||||||
{deleteId === row.original.id ? (
|
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setDeleteId(null)
|
navigate({
|
||||||
|
to: '/cms-testimonials/$id',
|
||||||
|
params: { id: row.original.id },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Batal
|
<Pencil className="size-3.5" />
|
||||||
|
Update
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setDeleteId(row.original.id)
|
setDeleteId(row.original.id);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<DeleteOutlined /> Delete
|
<Trash2 className="size-3.5" />
|
||||||
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: testimonials,
|
data: testimonials,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -178,53 +163,78 @@ function CmsTestimonialsPage() {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<BackofficeWrapper
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
title="CMS Testimonials"
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
description="Kelola testimonial pengguna"
|
||||||
<h1 className="text-p2 font-semibold">CMS Testimonials</h1>
|
>
|
||||||
</header>
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex justify-between items-center gap-8 mb-2">
|
<div className="relative w-full sm:max-w-sm">
|
||||||
<div className="relative w-full">
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Cari berdasarkan nama user"
|
placeholder="Cari nama user…"
|
||||||
className="pl-12 w-full max-h-full"
|
className="pl-9"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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>
|
|
||||||
<div className="relative">
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
className="flex gap-3 text-nowrap"
|
|
||||||
onClick={() => navigate({ to: '/cms-testimonials/create' })}
|
onClick={() => navigate({ to: '/cms-testimonials/create' })}
|
||||||
|
size="md"
|
||||||
>
|
>
|
||||||
<PlusOutlined />
|
<Plus className="size-4" />
|
||||||
Tambah Testimonial
|
Tambah Testimonial
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{isLoading ? (
|
{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
|
<DataTable
|
||||||
data={testimonials}
|
data={testimonials}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
pageSize={9}
|
|
||||||
table={table}
|
table={table}
|
||||||
|
manualPagination
|
||||||
|
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||||
|
currentPage={pagination.pageIndex + 1}
|
||||||
|
onPageChange={(p) =>
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardContent>
|
||||||
</main>
|
</Card>
|
||||||
</Fragment>
|
|
||||||
)
|
<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 { createFileRoute } from '@tanstack/react-router';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
import * as React from 'react';
|
||||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms'
|
import { Users, UserCog, CalendarClock, Activity, CircleCheck } from 'lucide-react';
|
||||||
import { For } from '@imphnen-frontend-service/utils'
|
import {
|
||||||
import { ReactElement } from 'react'
|
Card,
|
||||||
import { UserGrowthChart } from './_components/dashboard-dimentorin/chart/user-growth'
|
CardContent,
|
||||||
import { SessionStatusChart } from './_components/dashboard-dimentorin/chart/session-status'
|
CardDescription,
|
||||||
import { useMentorList, useUserList, useMySessions } from '@imphnen-frontend-service/service'
|
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')({
|
export const Route = createFileRoute('/_authenticated/dashboard-dimentorin')({
|
||||||
component: DashboardDimentorinPage,
|
component: DashboardDimentorinPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function DashboardDimentorinPage(): ReactElement {
|
type StatCardProps = {
|
||||||
const { data: mentorData } = useMentorList({ per_page: 5, sort_by: 'rating', order: 'desc' })
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
const { data: userData } = useUserList({ per_page: 1 })
|
label: string;
|
||||||
const { data: sessionsData } = useMySessions()
|
value: React.ReactNode;
|
||||||
|
};
|
||||||
const totalMentors = mentorData?.meta?.total ?? 0
|
|
||||||
const totalUsers = userData?.meta?.total ?? 0
|
|
||||||
const totalSessions = sessionsData?.total ?? 0
|
|
||||||
const topMentors = mentorData?.data ?? []
|
|
||||||
|
|
||||||
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 StatCard({ icon: Icon, label, value }: StatCardProps) {
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="Dimentorin.dev">
|
<Card>
|
||||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-5">Overview</h1>
|
<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">
|
||||||
<div className="space-y-14">
|
<Icon className="size-5" />
|
||||||
<div>
|
|
||||||
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
|
|
||||||
Overview
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
)}
|
<div className="flex flex-col">
|
||||||
</For>
|
<span className="text-2xl font-semibold leading-tight text-foreground">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
<div>
|
);
|
||||||
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
|
}
|
||||||
Trends & Analytics
|
|
||||||
</Button>
|
function DashboardDimentorinPage() {
|
||||||
|
const { data: mentorData } = useMentorList({
|
||||||
<div className="grid grid-cols-7 gap-x-5">
|
per_page: 5,
|
||||||
<div className="bg-white px-6 py-4 rounded-lg col-span-5">
|
sort_by: 'rating',
|
||||||
<div className="flex items-center justify-between mb-7">
|
order: 'desc',
|
||||||
<h2 className="font-semibold text-p3 text-neutral-700">User Growth</h2>
|
});
|
||||||
<div></div>
|
const { data: userData } = useUserList({ per_page: 1 });
|
||||||
</div>
|
const { data: sessionsData } = useMySessions();
|
||||||
<UserGrowthChart />
|
|
||||||
</div>
|
const totalMentors = mentorData?.meta?.total ?? 0;
|
||||||
<div className="bg-white px-6 py-4 rounded-lg col-span-2">
|
const totalUsers = userData?.meta?.total ?? 0;
|
||||||
<h2 className="font-semibold text-p3 text-neutral-700 mb-7">Session Status</h2>
|
const totalSessions = sessionsData?.total ?? 0;
|
||||||
<SessionStatusChart />
|
const topMentors = mentorData?.data ?? [];
|
||||||
</div>
|
const activeMentors = topMentors.filter((m) => m.status === 'active').length;
|
||||||
</div>
|
const completedSessions =
|
||||||
</div>
|
sessionsData?.sessions?.filter((s) => s.status === 'completed').length ?? 0;
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-x-5">
|
const topTopics = React.useMemo(() => {
|
||||||
<div className="bg-white px-7 py-4 rounded-lg">
|
const sessions = sessionsData?.sessions ?? [];
|
||||||
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top 5 Mentors</h2>
|
const topicCount: Record<string, number> = {};
|
||||||
|
sessions.forEach((s) => {
|
||||||
<div>
|
topicCount[s.topic] = (topicCount[s.topic] ?? 0) + 1;
|
||||||
<table className="w-full">
|
});
|
||||||
<thead>
|
return Object.entries(topicCount)
|
||||||
<tr className="text-label1 bg-primary-50 text-left rounded-full">
|
.sort(([, a], [, b]) => b - a)
|
||||||
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
|
.slice(0, 5);
|
||||||
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
|
}, [sessionsData]);
|
||||||
<th className="font-medium py-4 px-5 rounded-r-lg">Avg Rating</th>
|
|
||||||
</tr>
|
return (
|
||||||
</thead>
|
<BackofficeWrapper
|
||||||
|
title="Dimentorin Overview"
|
||||||
<tbody>
|
description="Ringkasan metrik platform mentoring"
|
||||||
{topMentors.slice(0, 5).map((mentor, index) => (
|
>
|
||||||
<tr key={mentor.id} className="shadow rounded-lg">
|
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
<td className="py-4 px-5">{index + 1}</td>
|
<StatCard icon={Users} label="Total Users" value={totalUsers} />
|
||||||
<td className="py-4 px-5">{mentor.fullname ?? '-'}</td>
|
<StatCard icon={UserCog} label="Total Mentors" value={totalMentors} />
|
||||||
<td className="py-4 px-5">{mentor.rating?.toFixed(1) ?? '-'}</td>
|
<StatCard
|
||||||
</tr>
|
icon={CalendarClock}
|
||||||
))}
|
label="Total Sessions"
|
||||||
{topMentors.length === 0 && (
|
value={totalSessions}
|
||||||
<tr>
|
/>
|
||||||
<td colSpan={3} className="py-4 px-5 text-center text-neutral-400">Belum ada data</td>
|
<StatCard
|
||||||
</tr>
|
icon={Activity}
|
||||||
)}
|
label="Active Mentors"
|
||||||
</tbody>
|
value={activeMentors}
|
||||||
</table>
|
/>
|
||||||
</div>
|
<StatCard
|
||||||
</div>
|
icon={CircleCheck}
|
||||||
|
label="Completed Sessions"
|
||||||
<div className="bg-white px-6 py-4 rounded-lg">
|
value={completedSessions}
|
||||||
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top Booked Mentoring Topics</h2>
|
/>
|
||||||
|
</section>
|
||||||
<div>
|
|
||||||
<table className="w-full">
|
<section className="grid grid-cols-1 gap-4 lg:grid-cols-7">
|
||||||
<thead>
|
<Card className="lg:col-span-5">
|
||||||
<tr className="text-label1 bg-primary-50 text-left font-medium">
|
<CardHeader>
|
||||||
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
|
<CardTitle>User Growth</CardTitle>
|
||||||
<th className="font-medium py-4 px-5 w-3/5">Topik</th>
|
<CardDescription>Pertumbuhan user dari waktu ke waktu</CardDescription>
|
||||||
<th className="font-medium py-4 px-5 rounded-r-lg">Total Sesi</th>
|
</CardHeader>
|
||||||
</tr>
|
<CardContent>
|
||||||
</thead>
|
<UserGrowthChart />
|
||||||
|
</CardContent>
|
||||||
<tbody>
|
</Card>
|
||||||
{(() => {
|
<Card className="lg:col-span-2">
|
||||||
const sessions = sessionsData?.sessions ?? []
|
<CardHeader>
|
||||||
const topicCount: Record<string, number> = {}
|
<CardTitle>Session Status</CardTitle>
|
||||||
sessions.forEach((s) => {
|
<CardDescription>Distribusi status sesi</CardDescription>
|
||||||
topicCount[s.topic] = (topicCount[s.topic] ?? 0) + 1
|
</CardHeader>
|
||||||
})
|
<CardContent>
|
||||||
const topTopics = Object.entries(topicCount)
|
<SessionStatusChart />
|
||||||
.sort(([, a], [, b]) => b - a)
|
</CardContent>
|
||||||
.slice(0, 5)
|
</Card>
|
||||||
if (topTopics.length === 0) {
|
</section>
|
||||||
return (
|
|
||||||
<tr>
|
<section className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<td colSpan={3} className="py-4 px-5 text-center text-neutral-400">Belum ada data</td>
|
<Card>
|
||||||
</tr>
|
<CardHeader>
|
||||||
)
|
<CardTitle>Top 5 Mentors</CardTitle>
|
||||||
}
|
<CardDescription>Mentor dengan rating tertinggi</CardDescription>
|
||||||
return topTopics.map(([topic, count], index) => (
|
</CardHeader>
|
||||||
<tr key={topic} className="shadow rounded-lg">
|
<CardContent>
|
||||||
<td className="py-4 px-5">{index + 1}</td>
|
<div className="rounded-md border border-neutral-200">
|
||||||
<td className="py-4 px-5">{topic}</td>
|
<Table>
|
||||||
<td className="py-4 px-5">{count}</td>
|
<TableHeader>
|
||||||
</tr>
|
<TableRow className="hover:bg-transparent">
|
||||||
))
|
<TableHead className="w-[10%]">No.</TableHead>
|
||||||
})()}
|
<TableHead>Nama Lengkap</TableHead>
|
||||||
</tbody>
|
<TableHead>Avg Rating</TableHead>
|
||||||
</table>
|
</TableRow>
|
||||||
</div>
|
</TableHeader>
|
||||||
</div>
|
<TableBody>
|
||||||
</div>
|
{topMentors.length === 0 ? (
|
||||||
</div>
|
<TableRow className="hover:bg-transparent">
|
||||||
</BackofficeWrapper>
|
<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>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</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 {
|
import {
|
||||||
PlusOutlined,
|
Plus,
|
||||||
ReloadOutlined,
|
UsersRound,
|
||||||
UsergroupAddOutlined,
|
UserMinus,
|
||||||
UsergroupDeleteOutlined,
|
UserCog,
|
||||||
UserSwitchOutlined,
|
RefreshCcw,
|
||||||
DeleteOutlined,
|
Pencil,
|
||||||
} from '@ant-design/icons'
|
Trash2,
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
MoreHorizontal,
|
||||||
import { Fragment, useState } from 'react'
|
} 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 {
|
import {
|
||||||
useUserList,
|
useUserList,
|
||||||
useGachaItemList,
|
useGachaItemList,
|
||||||
useDeleteGachaItem,
|
useDeleteGachaItem,
|
||||||
TGachaItemDto,
|
TGachaItemDto,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner';
|
||||||
|
import { DeleteConfirmDialog } from '../../components/list-helpers';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/dashboard')({
|
export const Route = createFileRoute('/_authenticated/dashboard')({
|
||||||
component: DashboardPage,
|
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() {
|
function DashboardPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const { data: usersData } = useUserList({ per_page: 1 })
|
const { data: usersData } = useUserList({ per_page: 1 });
|
||||||
const { data: gachaItemsData } = useGachaItemList({ per_page: 9 })
|
const { data: gachaItemsData } = useGachaItemList({ per_page: 9 });
|
||||||
const deleteItem = useDeleteGachaItem()
|
const deleteItem = useDeleteGachaItem();
|
||||||
|
|
||||||
const totalUsers = usersData?.meta?.total ?? 0
|
const totalUsers = usersData?.meta?.total ?? 0;
|
||||||
const gachaItems: TGachaItemDto[] = gachaItemsData?.data ?? []
|
const gachaItems: TGachaItemDto[] = gachaItemsData?.data ?? [];
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteItem.mutateAsync(id)
|
await deleteItem.mutateAsync(id);
|
||||||
toast.success('Item berhasil dihapus')
|
toast.success('Item berhasil dihapus');
|
||||||
setDeleteId(null)
|
setDeleteId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
toast.error('Item gagal dihapus')
|
toast.error('Item gagal dihapus');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<BackofficeWrapper
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
title="Gacha Dashboard"
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
description="Ringkasan statistik & daftar item gacha"
|
||||||
<h1 className="text-p2 font-semibold">Dashboard</h1>
|
>
|
||||||
</header>
|
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
<div className="flex justify-between gap-[40px] p-8 bg-white rounded-md">
|
icon={UsersRound}
|
||||||
<div className="w-full flex flex-col gap-[40px]">
|
label="Participants"
|
||||||
<section>
|
value={totalUsers.toLocaleString('id-ID')}
|
||||||
<h2 className="text-p2 font-medium text-primary-500 mb-8">
|
/>
|
||||||
Summary
|
<StatCard
|
||||||
</h2>
|
icon={RefreshCcw}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
label="Gacha Items"
|
||||||
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
value={(gachaItemsData?.meta?.total ?? 0).toLocaleString('id-ID')}
|
||||||
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
/>
|
||||||
<UsergroupAddOutlined className="text-[20px]" />
|
<StatCard icon={UserCog} label="Redeem" value="—" />
|
||||||
</div>
|
<StatCard icon={UserMinus} label="Inactive Users" value="—" />
|
||||||
<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>
|
||||||
|
|
||||||
<section className="flex flex-col gap-8">
|
<Card>
|
||||||
<div className="flex justify-between items-center">
|
<CardHeader>
|
||||||
<h2 className="text-p2 font-medium text-primary-500">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
Gacha Items
|
<div>
|
||||||
</h2>
|
<CardTitle>Gacha Items</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Daftar item yang tersedia di gacha
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
size="md"
|
||||||
size="sm"
|
|
||||||
className="items-end gap-3"
|
|
||||||
onClick={() => navigate({ to: '/dashboard/create' })}
|
onClick={() => navigate({ to: '/dashboard/create' })}
|
||||||
>
|
>
|
||||||
<span>Tambah Item</span>
|
<Plus className="size-4" />
|
||||||
<PlusOutlined className="text-[16px]" />
|
Tambah Item
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
<div className="flex flex-col gap-4 max-h-140 overflow-auto">
|
<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) => (
|
{gachaItems.map((item) => (
|
||||||
<div
|
<li
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="bg-white overflow-clip rounded-lg shadow-sm flex justify-between border border-neutral-100"
|
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="flex flex-col py-4 px-6 gap-[8px]">
|
<div className="min-w-0 flex-1">
|
||||||
<div>
|
<h3 className="truncate text-sm font-semibold text-primary-700">
|
||||||
<h3 className="text-p3 text-primary-500 font-medium">
|
|
||||||
{item.name}
|
{item.name}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center justify-start gap-10 text-label2 text-gray-500 mt-1">
|
<p className="mt-0.5 font-mono text-xs text-muted-foreground">
|
||||||
<span>{item.id}</span>
|
{item.id}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<DropdownMenu>
|
||||||
<div className="flex justify-start gap-2">
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button variant="text" size="icon" aria-label="Actions">
|
||||||
variant="text"
|
<MoreHorizontal className="size-4" />
|
||||||
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>
|
</Button>
|
||||||
{deleteId === item.id ? (
|
</DropdownMenuTrigger>
|
||||||
<div className="flex items-center gap-1">
|
<DropdownMenuContent align="end">
|
||||||
<span className="text-[10px] text-neutral-400">Yakin?</span>
|
<DropdownMenuItem
|
||||||
<Button
|
onSelect={() =>
|
||||||
variant="text"
|
navigate({
|
||||||
size="sm"
|
to: '/dashboard/$id',
|
||||||
className="text-[10px] text-red-500 p-0 font-normal hover:bg-transparent hover:text-red-700"
|
params: { id: item.id },
|
||||||
onClick={() => handleDelete(item.id)}
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Ya
|
<Pencil />
|
||||||
</Button>
|
<span>Edit</span>
|
||||||
<Button
|
</DropdownMenuItem>
|
||||||
variant="text"
|
<DropdownMenuItem
|
||||||
size="sm"
|
variant="destructive"
|
||||||
className="text-[10px] text-neutral-500 p-0 font-normal hover:bg-transparent"
|
onSelect={() => setDeleteId(item.id)}
|
||||||
onClick={() => setDeleteId(null)}
|
|
||||||
>
|
>
|
||||||
Batal
|
<Trash2 />
|
||||||
</Button>
|
<span>Hapus</span>
|
||||||
</div>
|
</DropdownMenuItem>
|
||||||
) : (
|
</DropdownMenuContent>
|
||||||
<Button
|
</DropdownMenu>
|
||||||
variant="text"
|
</li>
|
||||||
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>
|
</ul>
|
||||||
</section>
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<img
|
<DeleteConfirmDialog
|
||||||
src="gacha.webp"
|
open={!!deleteId}
|
||||||
alt=""
|
onOpenChange={(o) => !o && setDeleteId(null)}
|
||||||
className="rounded-lg hidden xl:block xl:min-w-[436px] h-auto object-cover"
|
onConfirm={() => deleteId && handleDelete(deleteId)}
|
||||||
|
title="Hapus item gacha ini?"
|
||||||
/>
|
/>
|
||||||
</div>
|
</BackofficeWrapper>
|
||||||
</main>
|
);
|
||||||
</Fragment>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -1,62 +1,91 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { SearchOutlined } from '@ant-design/icons'
|
import * as React from 'react';
|
||||||
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms'
|
import { Search, MessageSquare } from 'lucide-react';
|
||||||
import { BackofficeWrapper, DataTable } from '@imphnen-frontend-service/ui/organisms'
|
import {
|
||||||
import { cn, For } from '@imphnen-frontend-service/utils'
|
Badge,
|
||||||
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from '@tanstack/react-table'
|
Button,
|
||||||
import { ReactElement, useState } from 'react'
|
Card,
|
||||||
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service'
|
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 = {
|
export const Route = createFileRoute(
|
||||||
MENTORING: 'Mentoring',
|
'/_authenticated/feedback-review-dimentorin'
|
||||||
PLATFORM: 'Platform'
|
)({
|
||||||
} as const
|
|
||||||
type Tabs = typeof TABS[keyof typeof TABS]
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/feedback-review-dimentorin')({
|
|
||||||
component: FeedbackReviewDimentorinPage,
|
component: FeedbackReviewDimentorinPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function FeedbackReviewDimentorinPage(): ReactElement {
|
function FeedbackReviewDimentorinPage() {
|
||||||
const [activeTab, setActiveTab] = useState<Tabs>(TABS.MENTORING)
|
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 [rowSelection, setRowSelection] =
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
React.useState<RowSelectionState>({});
|
||||||
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: sessionsData, isLoading } = useMySessions(
|
const { data: sessionsData, isLoading } = useMySessions(
|
||||||
activeTab === TABS.MENTORING ? { status: 'completed' } : undefined
|
activeTab === 'mentoring' ? { status: 'completed' } : undefined
|
||||||
)
|
);
|
||||||
|
|
||||||
const sessions: TSessionListItem[] = activeTab === TABS.MENTORING
|
const allSessions: TSessionListItem[] =
|
||||||
? (sessionsData?.sessions ?? [])
|
activeTab === 'mentoring' ? (sessionsData?.sessions ?? []) : [];
|
||||||
: []
|
|
||||||
const totalItems = activeTab === TABS.MENTORING
|
const sessions = React.useMemo(() => {
|
||||||
? (sessionsData?.total ?? sessions.length)
|
return allSessions.filter((s) => {
|
||||||
: 0
|
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>[] = [
|
const columns: ColumnDef<TSessionListItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
meta: { cellClassName: cn('w-20') },
|
meta: { cellClassName: cn('w-10') },
|
||||||
header: ({ table }) => (
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
<input
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
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',
|
id: 'name',
|
||||||
@@ -81,39 +110,29 @@ function FeedbackReviewDimentorinPage(): ReactElement {
|
|||||||
header: 'Status',
|
header: 'Status',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const hasRating = !!row.original.rating
|
const hasRating = !!row.original.rating;
|
||||||
return (
|
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'}
|
{hasRating ? 'Done' : 'To Do'}
|
||||||
</div>
|
</Badge>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
meta: { cellClassName: cn('w-72') },
|
|
||||||
cell: () => (
|
cell: () => (
|
||||||
<Button
|
<Button variant="secondary" size="sm">
|
||||||
variant="primary"
|
<MessageSquare className="size-3.5" />
|
||||||
size="sm"
|
Lihat Feedback
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 w-max"
|
|
||||||
>
|
|
||||||
<SearchOutlined className="text-[16px]" /> Lihat Feedback
|
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: sessions,
|
data: sessions,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -121,64 +140,85 @@ function FeedbackReviewDimentorinPage(): ReactElement {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="Dimentorin.dev">
|
<BackofficeWrapper
|
||||||
<div className="mb-8 flex justify-between items-center">
|
title="Feedback & Review"
|
||||||
<h1 className="text-p1 font-semibold text-neutral-700">Feedback</h1>
|
description="Review feedback dari mentoring & platform"
|
||||||
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
>
|
||||||
<For data={Object.values(TABS)}>
|
<Card>
|
||||||
{(tab) => (
|
<CardContent className="pt-6">
|
||||||
<Button
|
<Tabs
|
||||||
key={tab}
|
value={activeTab}
|
||||||
variant="text"
|
onValueChange={(v) => {
|
||||||
className={cn('px-3 py-2 capitalize', activeTab === tab && 'bg-white')}
|
setActiveTab(v as 'mentoring' | 'platform');
|
||||||
onClick={() => {
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
setActiveTab(tab)
|
|
||||||
setPagination((p) => ({ ...p, pageIndex: 0 }))
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tab}
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
</Button>
|
<TabsList>
|
||||||
)}
|
<TabsTrigger value="mentoring">Mentoring</TabsTrigger>
|
||||||
</For>
|
<TabsTrigger value="platform">Platform</TabsTrigger>
|
||||||
</div>
|
</TabsList>
|
||||||
</div>
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<div className="relative w-full sm:w-72">
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<div className="flex justify-between items-center gap-5 mb-2">
|
|
||||||
<div className="relative w-full">
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Cari berdasarkan nama mentor/mentee"
|
className="pl-9"
|
||||||
className="pl-12 w-full max-h-full"
|
placeholder="Cari nama mentor/mentee…"
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
|
||||||
<SearchOutlined />
|
|
||||||
</div>
|
</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>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="mentoring" className="mt-4">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
) : activeTab === TABS.PLATFORM ? (
|
Memuat data…
|
||||||
<div className="text-center py-8 text-neutral-400">
|
|
||||||
Platform feedback tidak tersedia
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DataTable data={sessions} columns={columns} table={table} />
|
<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 }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import * as React from 'react'
|
import * as React from 'react';
|
||||||
import { Fragment, useState } from 'react'
|
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
Button,
|
||||||
EditOutlined,
|
Card,
|
||||||
DeleteOutlined,
|
CardContent,
|
||||||
PlusOutlined,
|
CardHeader,
|
||||||
} from '@ant-design/icons'
|
Input,
|
||||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
|
import {
|
||||||
|
DataTable,
|
||||||
|
BackofficeWrapper,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -16,143 +19,101 @@ import {
|
|||||||
PaginationState,
|
PaginationState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
useGachaItemList,
|
useGachaItemList,
|
||||||
useDeleteGachaItem,
|
useDeleteGachaItem,
|
||||||
TGachaItemDto,
|
TGachaItemDto,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
DeleteConfirmDialog,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/gacha-roll')({
|
export const Route = createFileRoute('/_authenticated/gacha-roll')({
|
||||||
component: GachaRollPage,
|
component: GachaRollPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function GachaRollPage() {
|
function GachaRollPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = React.useState('');
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
|
|
||||||
const { data: itemsData, isLoading } = useGachaItemList({
|
const { data: itemsData, isLoading } = useGachaItemList({
|
||||||
search,
|
search,
|
||||||
page: pagination.pageIndex + 1,
|
page: pagination.pageIndex + 1,
|
||||||
per_page: pagination.pageSize,
|
per_page: pagination.pageSize,
|
||||||
})
|
});
|
||||||
const deleteItem = useDeleteGachaItem()
|
const deleteItem = useDeleteGachaItem();
|
||||||
|
|
||||||
const items: TGachaItemDto[] = itemsData?.data ?? []
|
const items: TGachaItemDto[] = itemsData?.data ?? [];
|
||||||
const totalItems = itemsData?.meta?.total ?? items.length
|
const totalItems = itemsData?.meta?.total ?? items.length;
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteItem.mutateAsync(id)
|
await deleteItem.mutateAsync(id);
|
||||||
toast.success('Item berhasil dihapus')
|
toast.success('Item berhasil dihapus');
|
||||||
setDeleteId(null)
|
setDeleteId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
toast.error('Item gagal dihapus')
|
toast.error('Item gagal dihapus');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<TGachaItemDto>[] = [
|
const columns: ColumnDef<TGachaItemDto>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
header: ({ table }) => (
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
<input
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
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: 'No', accessorKey: 'id' },
|
||||||
|
{ header: 'Nama Item', accessorKey: 'name' },
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex gap-[8px]">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
navigate({ to: '/gacha-roll/$id', params: { id: row.original.id } })
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<EditOutlined /> Update
|
|
||||||
</Button>
|
|
||||||
{deleteId === row.original.id ? (
|
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setDeleteId(null)
|
navigate({
|
||||||
|
to: '/gacha-roll/$id',
|
||||||
|
params: { id: row.original.id },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Batal
|
<Pencil className="size-3.5" />
|
||||||
|
Update
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setDeleteId(row.original.id)
|
setDeleteId(row.original.id);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<DeleteOutlined /> Delete
|
<Trash2 className="size-3.5" />
|
||||||
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: items,
|
data: items,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -160,48 +121,61 @@ function GachaRollPage() {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<BackofficeWrapper
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
title="Gacha Roll"
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
description="Kelola item hadiah gacha"
|
||||||
<h1 className="text-p2 font-semibold">Gacha Roll</h1>
|
>
|
||||||
</header>
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex justify-between items-center gap-8 mb-2">
|
<div className="relative w-full sm:max-w-sm">
|
||||||
<div className="relative w-full">
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Cari berdasarkan nama item"
|
placeholder="Cari nama item…"
|
||||||
className="pl-12 w-full max-h-full"
|
className="pl-9"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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>
|
|
||||||
<div className="relative">
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
className="flex gap-3 text-nowrap"
|
|
||||||
onClick={() => navigate({ to: '/gacha-roll/create' })}
|
onClick={() => navigate({ to: '/gacha-roll/create' })}
|
||||||
|
size="md"
|
||||||
>
|
>
|
||||||
<PlusOutlined />
|
<Plus className="size-4" />
|
||||||
Tambah Item
|
Tambah Item
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{isLoading ? (
|
{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>
|
</CardContent>
|
||||||
</main>
|
</Card>
|
||||||
</Fragment>
|
|
||||||
)
|
<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 { createFileRoute } from '@tanstack/react-router';
|
||||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms'
|
import * as React from 'react';
|
||||||
import { FC, ReactElement } from 'react'
|
import { UsersRound, UserCog, ClipboardCheck } from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||||
import {
|
import {
|
||||||
getAdminUsers,
|
getAdminUsers,
|
||||||
getAdminTeams,
|
getAdminTeams,
|
||||||
getAdminSubmissions,
|
getAdminSubmissions,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/hackathon-dashboard')({
|
export const Route = createFileRoute('/_authenticated/hackathon-dashboard')({
|
||||||
component: HackathonDashboardPage,
|
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() {
|
function HackathonDashboardPage() {
|
||||||
const { data: usersData } = useQuery({
|
const { data: usersData } = useQuery({
|
||||||
queryKey: ['admin-users-count'],
|
queryKey: ['admin-users-count'],
|
||||||
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
|
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: teamsData } = useQuery({
|
const { data: teamsData } = useQuery({
|
||||||
queryKey: ['admin-teams-count'],
|
queryKey: ['admin-teams-count'],
|
||||||
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
|
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: submissionsData } = useQuery({
|
const { data: submissionsData } = useQuery({
|
||||||
queryKey: ['admin-submissions-count'],
|
queryKey: ['admin-submissions-count'],
|
||||||
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
|
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
|
||||||
})
|
});
|
||||||
|
|
||||||
const totalParticipants = usersData?.meta?.total_data ?? '??'
|
const totalParticipants = usersData?.meta?.total_data ?? '—';
|
||||||
const totalTeams = teamsData?.meta?.total_data ?? '??'
|
const totalTeams = teamsData?.meta?.total_data ?? '—';
|
||||||
const totalSubmissions = submissionsData?.meta?.total_data ?? '??'
|
const totalSubmissions = submissionsData?.meta?.total_data ?? '—';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
<BackofficeWrapper
|
||||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
|
title="Hackathon Dashboard"
|
||||||
|
description="IMPHNEN x Kolosal.ai Hackathon 2025"
|
||||||
<section className="grid grid-cols-5 gap-5">
|
>
|
||||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
<StatCard
|
||||||
{totalParticipants}
|
icon={UserCog}
|
||||||
</h3>
|
label="Total Participants"
|
||||||
<p className="text-neutral-400 text-p3">Total Participants</p>
|
value={totalParticipants}
|
||||||
</div>
|
/>
|
||||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
<StatCard icon={UsersRound} label="Total Teams" value={totalTeams} />
|
||||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
<StatCard
|
||||||
{totalTeams}
|
icon={ClipboardCheck}
|
||||||
</h3>
|
label="Total Project Submitted"
|
||||||
<p className="text-neutral-400 text-p3">Total Teams</p>
|
value={totalSubmissions}
|
||||||
</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>
|
|
||||||
</section>
|
</section>
|
||||||
</BackofficeWrapper>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,34 +1,27 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import {
|
import * as React from 'react';
|
||||||
FC,
|
import { Search, Eye } from 'lucide-react';
|
||||||
ReactElement,
|
import SubmissionModal from './_components/hackathon-submissions/submission-modal';
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useCallback,
|
|
||||||
} from 'react'
|
|
||||||
import SubmissionModal from './_components/hackathon-submissions/submission-modal'
|
|
||||||
import {
|
import {
|
||||||
BackofficeWrapper,
|
BackofficeWrapper,
|
||||||
DataTable,
|
DataTable,
|
||||||
} from '@imphnen-frontend-service/ui/organisms'
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
|
||||||
import { cn } from '@imphnen-frontend-service/utils'
|
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
Badge,
|
||||||
FilterOutlined,
|
Button,
|
||||||
LoadingOutlined,
|
Card,
|
||||||
EyeOutlined,
|
CardContent,
|
||||||
} from '@ant-design/icons'
|
CardHeader,
|
||||||
import { useQuery } from '@tanstack/react-query'
|
Input,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
getAdminSubmissions,
|
getAdminSubmissions,
|
||||||
TAdminSubmissionItem,
|
TAdminSubmissionItem,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
type SubmissionType = TAdminSubmissionItem
|
type SubmissionType = TAdminSubmissionItem;
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/hackathon-submissions')({
|
export const Route = createFileRoute('/_authenticated/hackathon-submissions')({
|
||||||
component: HackathonSubmissionsPage,
|
component: HackathonSubmissionsPage,
|
||||||
@@ -38,20 +31,20 @@ export const Route = createFileRoute('/_authenticated/hackathon-submissions')({
|
|||||||
per_page: Number(search.per_page) || 10,
|
per_page: Number(search.per_page) || 10,
|
||||||
status: (search.status as string) || 'all',
|
status: (search.status as string) || 'all',
|
||||||
}),
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
function HackathonSubmissionsPage() {
|
function HackathonSubmissionsPage() {
|
||||||
const searchParams = Route.useSearch()
|
const searchParams = Route.useSearch();
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const currentPage = Math.max(1, searchParams.page)
|
const currentPage = Math.max(1, searchParams.page);
|
||||||
const searchQuery = searchParams.search || ''
|
const searchQuery = searchParams.search || '';
|
||||||
const perPage = searchParams.per_page || 10
|
const perPage = searchParams.per_page || 10;
|
||||||
const statusFilter = searchParams.status || 'all'
|
const statusFilter = searchParams.status || 'all';
|
||||||
|
|
||||||
const [showSubmissionModal, setShowSubmissionModal] = useState(false)
|
const [showSubmissionModal, setShowSubmissionModal] = React.useState(false);
|
||||||
const [selectedSubmission, setSelectedSubmission] =
|
const [selectedSubmission, setSelectedSubmission] =
|
||||||
useState<SubmissionType | null>(null)
|
React.useState<SubmissionType | null>(null);
|
||||||
const [globalFilter, setGlobalFilter] = useState(searchQuery)
|
const [globalFilter, setGlobalFilter] = React.useState(searchQuery);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: submissionsResponse,
|
data: submissionsResponse,
|
||||||
@@ -74,12 +67,12 @@ function HackathonSubmissionsPage() {
|
|||||||
}),
|
}),
|
||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
gcTime: 5 * 60 * 1000,
|
gcTime: 5 * 60 * 1000,
|
||||||
})
|
});
|
||||||
|
|
||||||
const totalData = submissionsResponse?.meta?.total_data || 0
|
const totalData = submissionsResponse?.meta?.total_data || 0;
|
||||||
const totalPages = submissionsResponse?.meta?.total_page || 1
|
const totalPages = submissionsResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
const handlePageChange = useCallback(
|
const handlePageChange = React.useCallback(
|
||||||
(newPage: number) => {
|
(newPage: number) => {
|
||||||
navigate({
|
navigate({
|
||||||
search: {
|
search: {
|
||||||
@@ -88,23 +81,23 @@ function HackathonSubmissionsPage() {
|
|||||||
search: searchQuery || undefined,
|
search: searchQuery || undefined,
|
||||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
});
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
},
|
},
|
||||||
[navigate, perPage, searchQuery, statusFilter]
|
[navigate, perPage, searchQuery, statusFilter]
|
||||||
)
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
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(() => {
|
React.useEffect(() => {
|
||||||
setGlobalFilter(searchQuery)
|
setGlobalFilter(searchQuery);
|
||||||
}, [searchQuery])
|
}, [searchQuery]);
|
||||||
|
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = React.useCallback(() => {
|
||||||
navigate({
|
navigate({
|
||||||
search: {
|
search: {
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -112,56 +105,29 @@ function HackathonSubmissionsPage() {
|
|||||||
search: globalFilter.trim() || undefined,
|
search: globalFilter.trim() || undefined,
|
||||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
});
|
||||||
}, [globalFilter, navigate, perPage, statusFilter])
|
}, [globalFilter, navigate, perPage, statusFilter]);
|
||||||
|
|
||||||
const handleSearchKeyPress = useCallback(
|
const filteredData = React.useMemo<SubmissionType[]>(() => {
|
||||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
return (
|
||||||
if (e.key === 'Enter') {
|
((submissionsResponse?.data as any)?.data as SubmissionType[]) ??
|
||||||
handleSearch()
|
(submissionsResponse?.data as SubmissionType[]) ??
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleSearch]
|
|
||||||
)
|
|
||||||
|
|
||||||
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 handleShowSubmissionModal = useCallback(
|
|
||||||
(submission: SubmissionType) => {
|
|
||||||
setSelectedSubmission(submission)
|
|
||||||
setShowSubmissionModal(true)
|
|
||||||
},
|
|
||||||
[]
|
[]
|
||||||
)
|
);
|
||||||
|
}, [submissionsResponse]);
|
||||||
|
|
||||||
const handleCloseSubmissionModal = useCallback(() => {
|
const statusVariants: Record<string, 'success' | 'warning' | 'secondary'> = {
|
||||||
setShowSubmissionModal(false)
|
submitted: 'success',
|
||||||
setSelectedSubmission(null)
|
pending: 'warning',
|
||||||
}, [])
|
};
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const columns: ColumnDef<SubmissionType>[] = React.useMemo(
|
||||||
return submissionsResponse?.data?.data || submissionsResponse?.data || []
|
|
||||||
}, [submissionsResponse])
|
|
||||||
|
|
||||||
const columns: ColumnDef<SubmissionType>[] = useMemo(
|
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'project_name',
|
accessorKey: 'project_name',
|
||||||
header: 'Project Name',
|
header: 'Project Name',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-medium text-neutral-900">
|
<span className="font-medium text-foreground">
|
||||||
{row.original.project_name}
|
{row.original.project_name}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
@@ -171,7 +137,7 @@ function HackathonSubmissionsPage() {
|
|||||||
accessorKey: 'team_id',
|
accessorKey: 'team_id',
|
||||||
header: 'Team ID',
|
header: 'Team ID',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm text-neutral-700 font-mono">
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
{row.original.team_id}
|
{row.original.team_id}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
@@ -180,30 +146,21 @@ function HackathonSubmissionsPage() {
|
|||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const status = row.original.status
|
<Badge
|
||||||
return (
|
variant={statusVariants[row.original.status] ?? 'secondary'}
|
||||||
<span
|
className="capitalize"
|
||||||
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)}
|
{row.original.status}
|
||||||
</span>
|
</Badge>
|
||||||
)
|
),
|
||||||
},
|
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'submitted_at',
|
accessorKey: 'submitted_at',
|
||||||
header: 'Submitted',
|
header: 'Submitted',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-neutral-900 text-sm">
|
<span className="text-sm text-foreground">
|
||||||
{new Date(row.original.submitted_at).toLocaleDateString('en-US', {
|
{new Date(row.original.submitted_at).toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -217,109 +174,87 @@ function HackathonSubmissionsPage() {
|
|||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
meta: { cellClassName: cn('w-48') },
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex items-center gap-2 text-sm px-4 py-2"
|
onClick={() => {
|
||||||
onClick={() => handleShowSubmissionModal(row.original)}
|
setSelectedSubmission(row.original);
|
||||||
|
setShowSubmissionModal(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<EyeOutlined className="text-sm" />
|
<Eye className="size-3.5" />
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[handleShowSubmissionModal]
|
[]
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
<BackofficeWrapper
|
||||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
title="Project Submissions"
|
||||||
Project Submissions
|
description="IMPHNEN x Kolosal.ai Hackathon 2025"
|
||||||
</h1>
|
>
|
||||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
<Card>
|
||||||
<div className="flex flex-wrap gap-3 items-center justify-between">
|
<CardHeader>
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="relative">
|
<div className="relative w-full sm:max-w-sm">
|
||||||
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<input
|
<Input
|
||||||
type="text"
|
className="pl-9"
|
||||||
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="Cari nama project…"
|
||||||
placeholder="Search by project name..."
|
|
||||||
value={globalFilter}
|
value={globalFilter}
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
onKeyPress={handleSearchKeyPress}
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
</CardHeader>
|
||||||
{}
|
<CardContent>
|
||||||
{
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{}
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
Memuat submissions…
|
||||||
<span className="ml-3 text-neutral-600">
|
|
||||||
Loading submissions...
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : filteredData.length > 0 ? (
|
) : filteredData.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-sm text-neutral-600">
|
<div className="mb-3 text-xs text-muted-foreground">
|
||||||
Showing {filteredData.length} of {totalData} submissions (Page{' '}
|
Menampilkan {filteredData.length} dari {totalData} submissions
|
||||||
{currentPage} of {totalPages})
|
(page {currentPage} / {totalPages})
|
||||||
{isFetching && (
|
{isFetching && (
|
||||||
<span className="ml-2 text-primary-500">(Updating...)</span>
|
<span className="ml-2 text-primary-500">Updating…</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
data={filteredData}
|
data={filteredData}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
pageSize={perPage}
|
pageSize={perPage}
|
||||||
manualPagination={true}
|
manualPagination
|
||||||
pageCount={totalPages}
|
pageCount={totalPages}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12 text-neutral-500">
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
No submissions found. Try adjusting your filters.
|
Tidak ada submissions.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{selectedSubmission && (
|
{selectedSubmission && (
|
||||||
<SubmissionModal
|
<SubmissionModal
|
||||||
isOpen={showSubmissionModal}
|
isOpen={showSubmissionModal}
|
||||||
onClose={handleCloseSubmissionModal}
|
onClose={() => {
|
||||||
|
setShowSubmissionModal(false);
|
||||||
|
setSelectedSubmission(null);
|
||||||
|
}}
|
||||||
submission={selectedSubmission}
|
submission={selectedSubmission}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</BackofficeWrapper>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,37 +1,30 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import {
|
import * as React from 'react';
|
||||||
FC,
|
import { Search, Plus, Users as TeamIcon, Pencil, X } from 'lucide-react';
|
||||||
ReactElement,
|
import ModalTeamDetail from './_components/hackathon-teams/modal-team-detail-new';
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useCallback,
|
|
||||||
} from 'react'
|
|
||||||
import ModalTeamDetail from './_components/hackathon-teams/modal-team-detail-new'
|
|
||||||
import { CityFilterSelect } from '../../components/city-filter-select'
|
|
||||||
import {
|
import {
|
||||||
BackofficeWrapper,
|
BackofficeWrapper,
|
||||||
DataTable,
|
DataTable,
|
||||||
} from '@imphnen-frontend-service/ui/organisms'
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
|
||||||
import { cn } from '@imphnen-frontend-service/utils'
|
|
||||||
import {
|
import {
|
||||||
EditOutlined,
|
Avatar,
|
||||||
TeamOutlined,
|
AvatarFallback,
|
||||||
SearchOutlined,
|
AvatarImage,
|
||||||
FilterOutlined,
|
Badge,
|
||||||
PlusOutlined,
|
Button,
|
||||||
LoadingOutlined,
|
Card,
|
||||||
} from '@ant-design/icons'
|
CardContent,
|
||||||
import { useQuery } from '@tanstack/react-query'
|
CardHeader,
|
||||||
|
Input,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
getAdminTeams,
|
getAdminTeams,
|
||||||
TAdminTeamItem,
|
TAdminTeamItem,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
type TeamType = TAdminTeamItem
|
type TeamType = TAdminTeamItem;
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/hackathon-teams')({
|
export const Route = createFileRoute('/_authenticated/hackathon-teams')({
|
||||||
component: HackathonTeamsPage,
|
component: HackathonTeamsPage,
|
||||||
@@ -40,22 +33,21 @@ export const Route = createFileRoute('/_authenticated/hackathon-teams')({
|
|||||||
search: (search.search as string) || '',
|
search: (search.search as string) || '',
|
||||||
per_page: Number(search.per_page) || 10,
|
per_page: Number(search.per_page) || 10,
|
||||||
}),
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
function HackathonTeamsPage() {
|
function HackathonTeamsPage() {
|
||||||
const searchParams = Route.useSearch()
|
const searchParams = Route.useSearch();
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const currentPage = Math.max(1, searchParams.page)
|
const currentPage = Math.max(1, searchParams.page);
|
||||||
const searchQuery = searchParams.search || ''
|
const searchQuery = searchParams.search || '';
|
||||||
const perPage = searchParams.per_page || 10
|
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 [visibilityFilter, setVisibilityFilter] = useState('all')
|
const [showDetailModal, setShowDetailModal] = React.useState(false);
|
||||||
const [cityFilter, setCityFilter] = useState('all')
|
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 {
|
const {
|
||||||
data: teamsResponse,
|
data: teamsResponse,
|
||||||
@@ -78,12 +70,12 @@ function HackathonTeamsPage() {
|
|||||||
}),
|
}),
|
||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
gcTime: 5 * 60 * 1000,
|
gcTime: 5 * 60 * 1000,
|
||||||
})
|
});
|
||||||
|
|
||||||
const totalData = teamsResponse?.meta?.total_data || 0
|
const totalData = teamsResponse?.meta?.total_data || 0;
|
||||||
const totalPages = teamsResponse?.meta?.total_page || 1
|
const totalPages = teamsResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
const handlePageChange = useCallback(
|
const handlePageChange = React.useCallback(
|
||||||
(newPage: number) => {
|
(newPage: number) => {
|
||||||
navigate({
|
navigate({
|
||||||
search: {
|
search: {
|
||||||
@@ -91,142 +83,97 @@ function HackathonTeamsPage() {
|
|||||||
per_page: perPage !== 10 ? perPage : undefined,
|
per_page: perPage !== 10 ? perPage : undefined,
|
||||||
search: searchQuery || undefined,
|
search: searchQuery || undefined,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
});
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
},
|
},
|
||||||
[navigate, perPage, searchQuery]
|
[navigate, perPage, searchQuery]
|
||||||
)
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
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(() => {
|
React.useEffect(() => {
|
||||||
setGlobalFilter(searchQuery)
|
setGlobalFilter(searchQuery);
|
||||||
}, [searchQuery])
|
}, [searchQuery]);
|
||||||
|
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = React.useCallback(() => {
|
||||||
navigate({
|
navigate({
|
||||||
search: {
|
search: {
|
||||||
page: 1,
|
page: 1,
|
||||||
per_page: perPage !== 10 ? perPage : undefined,
|
per_page: perPage !== 10 ? perPage : undefined,
|
||||||
search: globalFilter.trim() || undefined,
|
search: globalFilter.trim() || undefined,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
});
|
||||||
}, [globalFilter, navigate, perPage])
|
}, [globalFilter, navigate, perPage]);
|
||||||
|
|
||||||
const handleSearchKeyPress = useCallback(
|
const filteredData = React.useMemo<TeamType[]>(() => {
|
||||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
return (
|
||||||
if (e.key === 'Enter') {
|
((teamsResponse?.data as any)?.data as TeamType[]) ??
|
||||||
handleSearch()
|
(teamsResponse?.data as TeamType[]) ??
|
||||||
}
|
[]
|
||||||
},
|
);
|
||||||
[handleSearch]
|
}, [teamsResponse]);
|
||||||
)
|
|
||||||
|
|
||||||
const handlePerPageChange = useCallback(
|
const handleShowDetailModal = React.useCallback((team: TeamType) => {
|
||||||
(newPerPage: number) => {
|
setSelectedTeam(team);
|
||||||
navigate({
|
setShowDetailModal(true);
|
||||||
search: {
|
}, []);
|
||||||
page: 1,
|
|
||||||
per_page: newPerPage,
|
|
||||||
search: searchQuery || undefined,
|
|
||||||
} as any,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[navigate, searchQuery]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleShowDetailModal = useCallback((team: TeamType) => {
|
const columns: ColumnDef<TeamType>[] = React.useMemo(
|
||||||
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(
|
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: 'Team',
|
header: 'Team',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const team = row.original
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<Avatar>
|
||||||
{team.logo ? (
|
<AvatarImage src={row.original.logo ?? undefined} alt={row.original.name} />
|
||||||
<img
|
<AvatarFallback>
|
||||||
src={team.logo}
|
<TeamIcon className="size-4 text-muted-foreground" />
|
||||||
alt={team.name}
|
</AvatarFallback>
|
||||||
className="w-full h-full object-cover"
|
</Avatar>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TeamOutlined className="text-neutral-400 text-lg" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p
|
<p
|
||||||
className="font-medium text-neutral-900 truncate max-w-sm"
|
className="truncate font-medium text-foreground"
|
||||||
title={team.name}
|
title={row.original.name}
|
||||||
>
|
>
|
||||||
{team.name}
|
{row.original.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
},
|
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'city',
|
accessorKey: 'city',
|
||||||
header: 'City',
|
header: 'City',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-neutral-700">{row.original.city}</span>
|
<span className="text-foreground">{row.original.city}</span>
|
||||||
),
|
),
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'visibility',
|
accessorKey: 'visibility',
|
||||||
header: 'Visibility',
|
header: 'Visibility',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const isPublic = row.original.visibility === 'public'
|
<Badge
|
||||||
return (
|
variant={
|
||||||
<span
|
row.original.visibility === 'public' ? 'success' : 'secondary'
|
||||||
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'}
|
{row.original.visibility === 'public' ? 'Public' : 'Private'}
|
||||||
</span>
|
</Badge>
|
||||||
)
|
),
|
||||||
},
|
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'leader',
|
id: 'leader',
|
||||||
header: 'Leader ID',
|
header: 'Leader ID',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-sm text-neutral-700 font-mono">
|
<div className="font-mono text-xs text-muted-foreground">
|
||||||
{row.original.leader_id}
|
{row.original.leader_id}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -236,7 +183,7 @@ function HackathonTeamsPage() {
|
|||||||
accessorKey: 'created_at',
|
accessorKey: 'created_at',
|
||||||
header: 'Created',
|
header: 'Created',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-neutral-900 text-sm">
|
<span className="text-sm text-foreground">
|
||||||
{new Date(row.original.created_at).toLocaleDateString('en-UK', {
|
{new Date(row.original.created_at).toLocaleDateString('en-UK', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -250,168 +197,127 @@ function HackathonTeamsPage() {
|
|||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
meta: { cellClassName: cn('w-48') },
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex items-center gap-2 text-sm px-4 py-2"
|
|
||||||
onClick={() => handleShowDetailModal(row.original)}
|
onClick={() => handleShowDetailModal(row.original)}
|
||||||
>
|
>
|
||||||
<EditOutlined className="text-sm" />
|
<Pencil className="size-3.5" />
|
||||||
Manage
|
Manage
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[handleShowDetailModal]
|
[handleShowDetailModal]
|
||||||
)
|
);
|
||||||
|
|
||||||
|
const hasActiveFilters = visibilityFilter !== 'all' || cityFilter !== 'all';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
<BackofficeWrapper
|
||||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
title="Hackathon Teams"
|
||||||
Team Management
|
description="IMPHNEN x Kolosal.ai Hackathon 2025"
|
||||||
</h1>
|
>
|
||||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
<Card>
|
||||||
<div className="flex flex-wrap gap-3 items-center justify-between">
|
<CardHeader>
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="relative">
|
<div className="relative w-full sm:max-w-sm">
|
||||||
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<input
|
<Input
|
||||||
type="text"
|
className="pl-9"
|
||||||
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="Cari nama atau kota…"
|
||||||
placeholder="Search teams by name or city..."
|
|
||||||
value={globalFilter}
|
value={globalFilter}
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
onKeyPress={handleSearchKeyPress}
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Button onClick={() => setShowNewTeamModal(true)} size="md">
|
||||||
<div className="relative">
|
<Plus className="size-4" />
|
||||||
<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" />
|
|
||||||
Add Team
|
Add Team
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
{(visibilityFilter !== 'all' || cityFilter !== 'all') && (
|
{hasActiveFilters && (
|
||||||
<div className="flex flex-wrap gap-2 items-center">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm text-neutral-600">Active filters:</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Active filters:
|
||||||
|
</span>
|
||||||
{visibilityFilter !== 'all' && (
|
{visibilityFilter !== 'all' && (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
<Badge variant="info" className="gap-1">
|
||||||
Visibility: {visibilityFilter}
|
Visibility: {visibilityFilter}
|
||||||
<button
|
<button onClick={() => setVisibilityFilter('all')}>
|
||||||
onClick={() => setVisibilityFilter('all')}
|
<X className="size-3" />
|
||||||
className="text-info-600 hover:text-info-800 cursor-pointer"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cityFilter !== 'all' && (
|
{cityFilter !== 'all' && (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
<Badge variant="success" className="gap-1">
|
||||||
City: {cityFilter}
|
City: {cityFilter}
|
||||||
<button
|
<button onClick={() => setCityFilter('all')}>
|
||||||
onClick={() => setCityFilter('all')}
|
<X className="size-3" />
|
||||||
className="text-green-600 hover:text-green-800 cursor-pointer"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="text"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setVisibilityFilter('all')
|
setVisibilityFilter('all');
|
||||||
setCityFilter('all')
|
setCityFilter('all');
|
||||||
setGlobalFilter('')
|
setGlobalFilter('');
|
||||||
}}
|
}}
|
||||||
className="text-sm text-neutral-600"
|
|
||||||
>
|
>
|
||||||
Clear All
|
Clear All
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||||
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
Memuat data teams…
|
||||||
<span className="ml-3 text-neutral-600">Loading teams...</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : filteredData.length > 0 ? (
|
) : filteredData.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-sm text-neutral-600">
|
<div className="text-xs text-muted-foreground">
|
||||||
Showing {filteredData.length} of {totalData} teams (Page{' '}
|
Menampilkan {filteredData.length} dari {totalData} teams (page{' '}
|
||||||
{currentPage} of {totalPages})
|
{currentPage} / {totalPages})
|
||||||
{isFetching && (
|
{isFetching && (
|
||||||
<span className="ml-2 text-primary-500">(Updating...)</span>
|
<span className="ml-2 text-primary-500">Updating…</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
data={filteredData}
|
data={filteredData}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
pageSize={perPage}
|
pageSize={perPage}
|
||||||
manualPagination={true}
|
manualPagination
|
||||||
pageCount={totalPages}
|
pageCount={totalPages}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12 text-neutral-500">
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
No teams found. Try adjusting your filters.
|
Tidak ada team. Coba ubah filter pencarian.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<ModalTeamDetail
|
<ModalTeamDetail
|
||||||
isOpen={showDetailModal}
|
isOpen={showDetailModal}
|
||||||
onClose={handleCloseDetailModal}
|
onClose={() => {
|
||||||
|
setShowDetailModal(false);
|
||||||
|
setSelectedTeam(null);
|
||||||
|
}}
|
||||||
team={selectedTeam}
|
team={selectedTeam}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModalTeamDetail
|
<ModalTeamDetail
|
||||||
isOpen={showNewTeamModal}
|
isOpen={showNewTeamModal}
|
||||||
onClose={handleCloseNewTeamModal}
|
onClose={() => setShowNewTeamModal(false)}
|
||||||
team={null}
|
team={null}
|
||||||
/>
|
/>
|
||||||
</BackofficeWrapper>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,48 +1,31 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import {
|
import * as React from 'react';
|
||||||
FC,
|
import { Search, Plus, User, Pencil, X } from 'lucide-react';
|
||||||
ReactElement,
|
import ModalUserDetail from './_components/hackathon-users/modal-user-detail';
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useCallback,
|
|
||||||
} from 'react'
|
|
||||||
import ModalUserDetail from './_components/hackathon-users/modal-user-detail'
|
|
||||||
import {
|
import {
|
||||||
BackofficeWrapper,
|
BackofficeWrapper,
|
||||||
DataTable,
|
DataTable,
|
||||||
} from '@imphnen-frontend-service/ui/organisms'
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
|
||||||
import { cn } from '@imphnen-frontend-service/utils'
|
|
||||||
import {
|
import {
|
||||||
EditOutlined,
|
Avatar,
|
||||||
UserOutlined,
|
AvatarFallback,
|
||||||
SearchOutlined,
|
AvatarImage,
|
||||||
FilterOutlined,
|
Badge,
|
||||||
PlusOutlined,
|
Button,
|
||||||
LoadingOutlined,
|
Card,
|
||||||
} from '@ant-design/icons'
|
CardContent,
|
||||||
import { CityFilterSelect } from '../../components/city-filter-select'
|
CardHeader,
|
||||||
import { useQuery } from '@tanstack/react-query'
|
Input,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
getAdminUsers,
|
getAdminUsers,
|
||||||
TAdminUserItem,
|
TAdminUserItem,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
type UserType = TAdminUserItem
|
type UserType = TAdminUserItem;
|
||||||
|
|
||||||
const skillsOptions = [
|
|
||||||
'Frontend Developer',
|
|
||||||
'Backend Developer',
|
|
||||||
'Full Stack Developer',
|
|
||||||
'DevOps Engineer',
|
|
||||||
'UI/UX Designer',
|
|
||||||
'Product Manager',
|
|
||||||
'Data Scientist',
|
|
||||||
'Mobile Developer',
|
|
||||||
]
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/hackathon-users')({
|
export const Route = createFileRoute('/_authenticated/hackathon-users')({
|
||||||
component: HackathonUsersPage,
|
component: HackathonUsersPage,
|
||||||
@@ -51,36 +34,28 @@ export const Route = createFileRoute('/_authenticated/hackathon-users')({
|
|||||||
search: (search.search as string) || '',
|
search: (search.search as string) || '',
|
||||||
per_page: Number(search.per_page) || 10,
|
per_page: Number(search.per_page) || 10,
|
||||||
}),
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
function HackathonUsersPage() {
|
function HackathonUsersPage() {
|
||||||
const searchParams = Route.useSearch()
|
const searchParams = Route.useSearch();
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const currentPage = Math.max(1, searchParams.page)
|
const currentPage = Math.max(1, searchParams.page);
|
||||||
const searchQuery = searchParams.search || ''
|
const searchQuery = searchParams.search || '';
|
||||||
const perPage = searchParams.per_page || 10
|
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 [statusFilter, setStatusFilter] = useState('all')
|
const [showDetailModal, setShowDetailModal] = React.useState(false);
|
||||||
const [cityFilter, setCityFilter] = useState('all')
|
const [showNewUserModal, setShowNewUserModal] = React.useState(false);
|
||||||
const [skillsFilter, setSkillsFilter] = useState<string[]>([])
|
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 {
|
const {
|
||||||
data: usersResponse,
|
data: usersResponse,
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: [
|
queryKey: ['admin-users', currentPage, perPage, statusFilter, searchQuery],
|
||||||
'admin-users',
|
|
||||||
currentPage,
|
|
||||||
perPage,
|
|
||||||
cityFilter,
|
|
||||||
statusFilter,
|
|
||||||
searchQuery,
|
|
||||||
],
|
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
getAdminUsers({
|
getAdminUsers({
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
@@ -89,12 +64,12 @@ function HackathonUsersPage() {
|
|||||||
}),
|
}),
|
||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
gcTime: 5 * 60 * 1000,
|
gcTime: 5 * 60 * 1000,
|
||||||
})
|
});
|
||||||
|
|
||||||
const totalData = usersResponse?.meta?.total_data || 0
|
const totalData = usersResponse?.meta?.total_data || 0;
|
||||||
const totalPages = usersResponse?.meta?.total_page || 1
|
const totalPages = usersResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
const handlePageChange = useCallback(
|
const handlePageChange = React.useCallback(
|
||||||
(newPage: number) => {
|
(newPage: number) => {
|
||||||
navigate({
|
navigate({
|
||||||
search: {
|
search: {
|
||||||
@@ -102,112 +77,73 @@ function HackathonUsersPage() {
|
|||||||
per_page: perPage !== 10 ? perPage : undefined,
|
per_page: perPage !== 10 ? perPage : undefined,
|
||||||
search: searchQuery || undefined,
|
search: searchQuery || undefined,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
});
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
},
|
},
|
||||||
[navigate, perPage, searchQuery]
|
[navigate, perPage, searchQuery]
|
||||||
)
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
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(() => {
|
React.useEffect(() => {
|
||||||
setGlobalFilter(searchQuery)
|
setGlobalFilter(searchQuery);
|
||||||
}, [searchQuery])
|
}, [searchQuery]);
|
||||||
|
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = React.useCallback(() => {
|
||||||
navigate({
|
navigate({
|
||||||
search: {
|
search: {
|
||||||
page: 1,
|
page: 1,
|
||||||
per_page: perPage !== 10 ? perPage : undefined,
|
per_page: perPage !== 10 ? perPage : undefined,
|
||||||
search: globalFilter.trim() || undefined,
|
search: globalFilter.trim() || undefined,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
});
|
||||||
}, [globalFilter, navigate, perPage])
|
}, [globalFilter, navigate, perPage]);
|
||||||
|
|
||||||
const handleSearchKeyPress = useCallback(
|
const handleShowDetailModal = React.useCallback((user: UserType) => {
|
||||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
setSelectedUser(user);
|
||||||
if (e.key === 'Enter') {
|
setShowDetailModal(true);
|
||||||
handleSearch()
|
}, []);
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleSearch]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handlePerPageChange = useCallback(
|
const filteredData = React.useMemo(() => {
|
||||||
(newPerPage: number) => {
|
const usersData: UserType[] =
|
||||||
navigate({
|
((usersResponse?.data as any)?.data as UserType[]) ??
|
||||||
search: {
|
(usersResponse?.data as UserType[]) ??
|
||||||
page: 1,
|
[];
|
||||||
per_page: newPerPage,
|
return usersData.filter((user) => {
|
||||||
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) => {
|
|
||||||
if (statusFilter !== 'all') {
|
if (statusFilter !== 'all') {
|
||||||
const isActive = statusFilter === 'active'
|
const isActive = statusFilter === 'active';
|
||||||
if (user.is_active !== isActive) return false
|
if (user.is_active !== isActive) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (skillsFilter.length > 0) {
|
if (skillsFilter.length > 0) {
|
||||||
const userSkills = user.skills || []
|
const userSkills = user.skills || [];
|
||||||
const hasMatchingSkill = skillsFilter.some((skill) =>
|
const hasMatchingSkill = skillsFilter.some((skill) =>
|
||||||
userSkills.includes(skill)
|
userSkills.includes(skill)
|
||||||
)
|
);
|
||||||
if (!hasMatchingSkill) return false
|
if (!hasMatchingSkill) return false;
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [usersResponse, statusFilter, skillsFilter]);
|
||||||
|
|
||||||
return true
|
const columns: ColumnDef<UserType>[] = React.useMemo(
|
||||||
})
|
|
||||||
}, [usersResponse, statusFilter, skillsFilter])
|
|
||||||
|
|
||||||
const columns: ColumnDef<UserType>[] = useMemo(
|
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'fullname',
|
accessorKey: 'fullname',
|
||||||
header: 'User',
|
header: 'User',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<Avatar className="size-9">
|
||||||
{row.original.avatar ? (
|
<AvatarImage src={row.original.avatar ?? undefined} alt={row.original.fullname} />
|
||||||
<img
|
<AvatarFallback>
|
||||||
src={row.original.avatar}
|
<User className="size-4 text-muted-foreground" />
|
||||||
alt={row.original.fullname}
|
</AvatarFallback>
|
||||||
className="w-full h-full object-cover"
|
</Avatar>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<UserOutlined className="text-neutral-500 text-lg" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
<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}
|
{row.original.fullname}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,30 +155,22 @@ function HackathonUsersPage() {
|
|||||||
accessorKey: 'skills',
|
accessorKey: 'skills',
|
||||||
header: 'Skills',
|
header: 'Skills',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const skills = row.original.skills || []
|
const skills = row.original.skills || [];
|
||||||
|
if (skills.length === 0) {
|
||||||
|
return <span className="text-muted-foreground">-</span>;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1 max-w-xs">
|
<div className="flex flex-wrap gap-1">
|
||||||
{skills.length > 0 ? (
|
|
||||||
<>
|
|
||||||
{skills.slice(0, 2).map((skill, index) => (
|
{skills.slice(0, 2).map((skill, index) => (
|
||||||
<span
|
<Badge key={index} variant="success">
|
||||||
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', '')}
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||||
</span>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
{skills.length > 2 && (
|
{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">
|
<Badge variant="secondary">+{skills.length - 2}</Badge>
|
||||||
+{skills.length - 2}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-neutral-400">-</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
},
|
},
|
||||||
@@ -250,7 +178,7 @@ function HackathonUsersPage() {
|
|||||||
accessorKey: 'location',
|
accessorKey: 'location',
|
||||||
header: 'Location',
|
header: 'Location',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-neutral-700">{row.original.location}</span>
|
<span className="text-foreground">{row.original.location}</span>
|
||||||
),
|
),
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
},
|
},
|
||||||
@@ -259,16 +187,16 @@ function HackathonUsersPage() {
|
|||||||
header: 'Status',
|
header: 'Status',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-2 h-2 rounded-full',
|
'size-2 rounded-full',
|
||||||
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
|
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-sm font-medium',
|
'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'}
|
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||||
@@ -277,18 +205,18 @@ function HackathonUsersPage() {
|
|||||||
),
|
),
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
sortingFn: (rowA, rowB) => {
|
sortingFn: (rowA, rowB) => {
|
||||||
const aActive = rowA.original.is_active
|
const a = rowA.original.is_active;
|
||||||
const bActive = rowB.original.is_active
|
const b = rowB.original.is_active;
|
||||||
if (aActive && !bActive) return -1
|
if (a && !b) return -1;
|
||||||
if (!aActive && bActive) return 1
|
if (!a && b) return 1;
|
||||||
return 0
|
return 0;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'created_at',
|
accessorKey: 'created_at',
|
||||||
header: 'Joined',
|
header: 'Joined',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-neutral-900 text-sm">
|
<span className="text-sm text-foreground">
|
||||||
{new Date(row.original.created_at).toLocaleDateString('en-US', {
|
{new Date(row.original.created_at).toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -302,194 +230,135 @@ function HackathonUsersPage() {
|
|||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
meta: { cellClassName: cn('w-48') },
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex items-center gap-2 text-sm px-4 py-2"
|
|
||||||
onClick={() => handleShowDetailModal(row.original)}
|
onClick={() => handleShowDetailModal(row.original)}
|
||||||
>
|
>
|
||||||
<EditOutlined className="text-sm" />
|
<Pencil className="size-3.5" />
|
||||||
Manage
|
Manage
|
||||||
</Button>
|
</Button>
|
||||||
{
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[handleShowDetailModal]
|
[handleShowDetailModal]
|
||||||
)
|
);
|
||||||
|
|
||||||
|
const hasActiveFilters = statusFilter !== 'all' || skillsFilter.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
<BackofficeWrapper
|
||||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
title="Hackathon Users"
|
||||||
User Management
|
description="IMPHNEN x Kolosal.ai Hackathon 2025"
|
||||||
</h1>
|
>
|
||||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
<Card>
|
||||||
<div className="flex flex-wrap gap-3 items-center justify-between">
|
<CardHeader>
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="relative">
|
<div className="relative w-full sm:max-w-sm">
|
||||||
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<input
|
<Input
|
||||||
type="text"
|
className="pl-9"
|
||||||
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="Cari nama atau lokasi…"
|
||||||
placeholder="Search users by name or location..."
|
|
||||||
value={globalFilter}
|
value={globalFilter}
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
onKeyPress={handleSearchKeyPress}
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Button onClick={() => setShowNewUserModal(true)} size="md">
|
||||||
<div className="relative">
|
<Plus className="size-4" />
|
||||||
<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" />
|
|
||||||
Add User
|
Add User
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
{(skillsFilter.length > 0 ||
|
{hasActiveFilters && (
|
||||||
statusFilter !== 'all' ||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
cityFilter !== 'all') && (
|
<span className="text-sm text-muted-foreground">
|
||||||
<div className="flex flex-wrap gap-2 items-center">
|
Active filters:
|
||||||
<span className="text-sm text-neutral-600">Active filters:</span>
|
</span>
|
||||||
|
|
||||||
{statusFilter !== 'all' && (
|
{statusFilter !== 'all' && (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
<Badge variant="info" className="gap-1">
|
||||||
Status: {statusFilter}
|
Status: {statusFilter}
|
||||||
<button
|
<button
|
||||||
onClick={() => setStatusFilter('all')}
|
onClick={() => setStatusFilter('all')}
|
||||||
className="text-info-600 hover:text-info-800 cursor-pointer"
|
aria-label="Clear status filter"
|
||||||
>
|
>
|
||||||
✕
|
<X className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{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) => (
|
{skillsFilter.map((skill) => (
|
||||||
<span
|
<Badge key={skill} variant="secondary" className="gap-1">
|
||||||
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', '')}
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
|
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
|
||||||
}
|
}
|
||||||
className="text-purple-600 hover:text-purple-800 cursor-pointer"
|
aria-label={`Clear ${skill} filter`}
|
||||||
>
|
>
|
||||||
✕
|
<X className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="text"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStatusFilter('all')
|
setStatusFilter('all');
|
||||||
setCityFilter('all')
|
setSkillsFilter([]);
|
||||||
setSkillsFilter([])
|
setGlobalFilter('');
|
||||||
setGlobalFilter('')
|
|
||||||
}}
|
}}
|
||||||
className="text-sm text-neutral-600"
|
|
||||||
>
|
>
|
||||||
Clear All
|
Clear All
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||||
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
<span>Memuat data users…</span>
|
||||||
<span className="ml-3 text-neutral-600">Loading users...</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : filteredData.length > 0 ? (
|
) : filteredData.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-sm text-neutral-600">
|
<div className="text-xs text-muted-foreground">
|
||||||
Showing {filteredData.length} of {totalData} users (Page{' '}
|
Menampilkan {filteredData.length} dari {totalData} users (page{' '}
|
||||||
{currentPage} of {totalPages})
|
{currentPage} / {totalPages})
|
||||||
{isFetching && (
|
{isFetching && (
|
||||||
<span className="ml-2 text-primary-500">(Updating...)</span>
|
<span className="ml-2 text-primary-500">Updating…</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
data={filteredData}
|
data={filteredData}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
pageSize={perPage}
|
pageSize={perPage}
|
||||||
manualPagination={true}
|
manualPagination
|
||||||
pageCount={totalPages}
|
pageCount={totalPages}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12 text-neutral-500">
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
No users found. Try adjusting your filters.
|
Tidak ada user. Coba ubah filter pencarian.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<ModalUserDetail
|
<ModalUserDetail
|
||||||
isOpen={showDetailModal}
|
isOpen={showDetailModal}
|
||||||
onClose={handleCloseDetailModal}
|
onClose={() => {
|
||||||
|
setShowDetailModal(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
}}
|
||||||
user={selectedUser}
|
user={selectedUser}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModalUserDetail
|
<ModalUserDetail
|
||||||
isOpen={showNewUserModal}
|
isOpen={showNewUserModal}
|
||||||
onClose={handleCloseNewUserModal}
|
onClose={() => setShowNewUserModal(false)}
|
||||||
user={null}
|
user={null}
|
||||||
/>
|
/>
|
||||||
</BackofficeWrapper>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { Fragment, useState } from 'react'
|
import * as React from 'react';
|
||||||
|
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
Button,
|
||||||
EditOutlined,
|
Card,
|
||||||
DeleteOutlined,
|
CardContent,
|
||||||
PlusOutlined,
|
CardHeader,
|
||||||
} from '@ant-design/icons'
|
Input,
|
||||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
|
import {
|
||||||
|
DataTable,
|
||||||
|
BackofficeWrapper,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -15,144 +19,101 @@ import {
|
|||||||
PaginationState,
|
PaginationState,
|
||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
usePermissionList,
|
usePermissionList,
|
||||||
useDeletePermission,
|
useDeletePermission,
|
||||||
TPermissionItem,
|
TPermissionItem,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
import React from 'react'
|
import { toast } from 'sonner';
|
||||||
import { toast } from 'sonner'
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
DeleteConfirmDialog,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/permissions')({
|
export const Route = createFileRoute('/_authenticated/permissions')({
|
||||||
component: PermissionsPage,
|
component: PermissionsPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function PermissionsPage() {
|
function PermissionsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = React.useState('');
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
|
|
||||||
const { data: permissionsData, isLoading } = usePermissionList({
|
const { data: permissionsData, isLoading } = usePermissionList({
|
||||||
search,
|
search,
|
||||||
page: pagination.pageIndex + 1,
|
page: pagination.pageIndex + 1,
|
||||||
per_page: pagination.pageSize,
|
per_page: pagination.pageSize,
|
||||||
})
|
});
|
||||||
const deletePermission = useDeletePermission()
|
const deletePermission = useDeletePermission();
|
||||||
|
|
||||||
const permissions: TPermissionItem[] = permissionsData?.data ?? []
|
const permissions: TPermissionItem[] = permissionsData?.data ?? [];
|
||||||
const totalItems = permissionsData?.meta?.total ?? permissions.length
|
const totalItems = permissionsData?.meta?.total ?? permissions.length;
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deletePermission.mutateAsync(id)
|
await deletePermission.mutateAsync(id);
|
||||||
toast.success('Data permissions berhasil dihapus')
|
toast.success('Data permission berhasil dihapus');
|
||||||
setDeleteId(null)
|
setDeleteId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
toast.error('Data permissions gagal dihapus')
|
toast.error('Data permission gagal dihapus');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<TPermissionItem>[] = [
|
const columns: ColumnDef<TPermissionItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
header: ({ table }) => (
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
<input
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
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: 'No', accessorKey: 'id' },
|
||||||
|
{ header: 'Name', accessorKey: 'name' },
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex gap-[8px]">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
navigate({ to: '/permissions/$id', params: { id: row.original.id } })
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<EditOutlined /> Update
|
|
||||||
</Button>
|
|
||||||
{deleteId === row.original.id ? (
|
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setDeleteId(null)
|
navigate({
|
||||||
|
to: '/permissions/$id',
|
||||||
|
params: { id: row.original.id },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Batal
|
<Pencil className="size-3.5" />
|
||||||
|
Update
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setDeleteId(row.original.id)
|
setDeleteId(row.original.id);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<DeleteOutlined /> Delete
|
<Trash2 className="size-3.5" />
|
||||||
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: permissions,
|
data: permissions,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -160,53 +121,61 @@ function PermissionsPage() {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<BackofficeWrapper
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
title="Permissions"
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
description="Kelola hak akses sistem"
|
||||||
<h1 className="text-p2 font-semibold">Permissions</h1>
|
>
|
||||||
</header>
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex justify-between items-center gap-8 mb-2">
|
<div className="relative w-full sm:max-w-sm">
|
||||||
<div className="relative w-full">
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Cari berdasarkan nama permissions"
|
placeholder="Cari nama permission…"
|
||||||
className="pl-12 w-full max-h-full"
|
className="pl-9"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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>
|
|
||||||
<div className="relative">
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
className="flex gap-3 text-nowrap"
|
|
||||||
onClick={() => navigate({ to: '/permissions/create' })}
|
onClick={() => navigate({ to: '/permissions/create' })}
|
||||||
|
size="md"
|
||||||
>
|
>
|
||||||
<PlusOutlined />
|
<Plus className="size-4" />
|
||||||
Tambah Permissions
|
Tambah Permission
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{isLoading ? (
|
{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
|
<DataTable
|
||||||
data={permissions}
|
data={permissions}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
pageSize={9}
|
|
||||||
table={table}
|
table={table}
|
||||||
|
manualPagination
|
||||||
|
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||||
|
currentPage={pagination.pageIndex + 1}
|
||||||
|
onPageChange={(p) =>
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardContent>
|
||||||
</main>
|
</Card>
|
||||||
</Fragment>
|
|
||||||
)
|
<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 { createFileRoute } from '@tanstack/react-router';
|
||||||
import * as React from 'react'
|
import * as React from 'react';
|
||||||
import { FC, Fragment, ReactElement, useState } from 'react'
|
import { Filter as FilterIcon, Search, ClipboardCheck } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
FilterOutlined,
|
Badge,
|
||||||
SearchOutlined,
|
Button,
|
||||||
AuditOutlined,
|
Card,
|
||||||
} from '@ant-design/icons'
|
CardContent,
|
||||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
CardHeader,
|
||||||
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms'
|
Input,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
Filter,
|
||||||
|
BackofficeWrapper,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -15,19 +24,23 @@ import {
|
|||||||
PaginationState,
|
PaginationState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table';
|
||||||
import ModalProcessDelivery from './_components/prizes/modal-process-item'
|
import ModalProcessDelivery from './_components/prizes/modal-process-item';
|
||||||
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
type OrderValid = 'valid' | 'invalid' | 'unchecked'
|
type OrderValid = 'valid' | 'invalid' | 'unchecked';
|
||||||
type Status = 'undelivered' | 'delivered'
|
type Status = 'undelivered' | 'delivered';
|
||||||
|
|
||||||
interface Prize {
|
interface Prize {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
orderValid: OrderValid
|
orderValid: OrderValid;
|
||||||
items: string
|
items: string;
|
||||||
address: string
|
address: string;
|
||||||
status: Status
|
status: Status;
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
@@ -37,7 +50,7 @@ const items = [
|
|||||||
'Sticker Isi 3',
|
'Sticker Isi 3',
|
||||||
'Sticker Isi 5',
|
'Sticker Isi 5',
|
||||||
'Gelang Karet',
|
'Gelang Karet',
|
||||||
]
|
];
|
||||||
|
|
||||||
const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
|
const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
id: i + 1,
|
id: i + 1,
|
||||||
@@ -50,136 +63,103 @@ const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
|
|||||||
items: items[i % items.length],
|
items: items[i % items.length],
|
||||||
address: 'Jl. Pantai Cibaduyut Indah',
|
address: 'Jl. Pantai Cibaduyut Indah',
|
||||||
status: (i % 3 === 0 ? 'undelivered' : 'delivered') as Status,
|
status: (i % 3 === 0 ? 'undelivered' : 'delivered') as Status,
|
||||||
}))
|
}));
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/prizes')({
|
export const Route = createFileRoute('/_authenticated/prizes')({
|
||||||
component: PrizesPage,
|
component: PrizesPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function PrizesPage() {
|
function PrizesPage() {
|
||||||
const [showModalProcessDelivery, setShowModalProcessDelivery] =
|
const [showModalProcessDelivery, setShowModalProcessDelivery] =
|
||||||
useState(false)
|
React.useState(false);
|
||||||
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
const [showFilter, setShowFilter] = useState(false)
|
const [showFilter, setShowFilter] = React.useState(false);
|
||||||
|
|
||||||
const deliveryOptions = [
|
const deliveryOptions = [
|
||||||
{ id: 'option1', value: 'undelivered', label: 'Undelivered' },
|
{ id: 'undelivered', value: 'undelivered', label: 'Undelivered' },
|
||||||
{ id: 'option1', value: 'delivered', label: 'Delivered' },
|
{ 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>[] = [
|
const columns: ColumnDef<Prize>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
header: ({ table }) => (
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
<input
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
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: 'No', accessorKey: 'id' },
|
||||||
|
{ header: 'Nama Lengkap', accessorKey: 'name' },
|
||||||
{
|
{
|
||||||
header: 'Order Valid?',
|
header: 'Order Valid?',
|
||||||
accessorKey: 'orderValid',
|
accessorKey: 'orderValid',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const status = row.original.orderValid
|
<Badge variant={orderValidVariants[row.original.orderValid]}>
|
||||||
const statusColors: Record<OrderValid, string> = {
|
{orderValidText[row.original.orderValid]}
|
||||||
valid: 'bg-success-200 text-success-500',
|
</Badge>
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
|
{ header: 'Items', accessorKey: 'items' },
|
||||||
|
{ header: 'Alamat Pengiriman', accessorKey: 'address' },
|
||||||
{
|
{
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const status = row.original.status
|
<Badge variant={statusVariants[row.original.status]}>
|
||||||
const statusColors: Record<Status, string> = {
|
{statusText[row.original.status]}
|
||||||
delivered: 'bg-success-200 text-success-500',
|
</Badge>
|
||||||
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>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
cell: ({ row }) => (
|
cell: () => (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setShowModalProcessDelivery(true)
|
setShowModalProcessDelivery(true);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 w-full"
|
|
||||||
>
|
>
|
||||||
<AuditOutlined className="text-[16px]" /> Process
|
<ClipboardCheck className="size-3.5" />
|
||||||
|
Process
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: mockData,
|
data: mockData,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -187,59 +167,55 @@ function PrizesPage() {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
manualPagination: false,
|
manualPagination: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<BackofficeWrapper
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
title="Data Pengiriman Hadiah"
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
description="Proses pengiriman hadiah ke pemenang"
|
||||||
<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">
|
|
||||||
<Input
|
|
||||||
placeholder="Cari berdasarkan nama lengkap, nomor order Shopee"
|
|
||||||
className="pl-12 w-full max-h-full"
|
|
||||||
/>
|
|
||||||
<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 />
|
<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 nomor order…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Popover open={showFilter} onOpenChange={setShowFilter}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="secondary" size="md">
|
||||||
|
<FilterIcon className="size-4" />
|
||||||
Filters
|
Filters
|
||||||
</Button>
|
</Button>
|
||||||
{showFilter && (
|
</PopoverTrigger>
|
||||||
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
|
<PopoverContent align="end" className="w-auto p-0">
|
||||||
<Filter
|
<Filter
|
||||||
options={deliveryOptions}
|
options={deliveryOptions}
|
||||||
|
title="Status"
|
||||||
onClose={() => setShowFilter(false)}
|
onClose={() => setShowFilter(false)}
|
||||||
onFilterChange={(value) => {
|
onFilterChange={(value) => {
|
||||||
console.log('Selected filter:', value)
|
console.log('Selected filter:', value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardHeader>
|
||||||
</div>
|
<CardContent>
|
||||||
</div>
|
|
||||||
<DataTable data={mockData} columns={columns} table={table} />
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
</section>
|
</CardContent>
|
||||||
</main>
|
</Card>
|
||||||
|
|
||||||
<ModalProcessDelivery
|
<ModalProcessDelivery
|
||||||
isOpen={showModalProcessDelivery}
|
isOpen={showModalProcessDelivery}
|
||||||
onClose={() => setShowModalProcessDelivery(false)}
|
onClose={() => setShowModalProcessDelivery(false)}
|
||||||
handleProcessDelivery={() => {
|
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 { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons'
|
import * as React from 'react';
|
||||||
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms'
|
import { Search, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||||
import { BackofficeWrapper, DataTable } from '@imphnen-frontend-service/ui/organisms'
|
import {
|
||||||
import { cn } from '@imphnen-frontend-service/utils'
|
Badge,
|
||||||
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from '@tanstack/react-table'
|
Button,
|
||||||
import { useState } from 'react'
|
Card,
|
||||||
import { toast } from 'sonner'
|
CardContent,
|
||||||
import { useRoadmapList, useDeleteRoadmap, TRoadmapListItem, TRoadmapStatus } from '@imphnen-frontend-service/service'
|
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')({
|
export const Route = createFileRoute('/_authenticated/roadmap-dimentorin')({
|
||||||
component: RoadmapDimentorinPage,
|
component: RoadmapDimentorinPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function RoadmapDimentorinPage(): React.ReactElement {
|
function RoadmapDimentorinPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = React.useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('')
|
const [statusFilter, setStatusFilter] = React.useState('all');
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
const [deletingId, setDeletingId] = React.useState<string | null>(null);
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: roadmapData, isLoading } = useRoadmapList()
|
const { data: roadmapData, isLoading } = useRoadmapList();
|
||||||
const deleteRoadmap = useDeleteRoadmap()
|
const deleteRoadmap = useDeleteRoadmap();
|
||||||
|
|
||||||
const allItems: TRoadmapListItem[] = roadmapData ?? []
|
const allItems: TRoadmapListItem[] = roadmapData ?? [];
|
||||||
const filteredItems = allItems.filter((item) => {
|
const filteredItems = allItems.filter((item) => {
|
||||||
const matchSearch = !search || item.title.toLowerCase().includes(search.toLowerCase())
|
const matchSearch =
|
||||||
const matchStatus = !statusFilter || item.status === statusFilter
|
!search || item.title.toLowerCase().includes(search.toLowerCase());
|
||||||
return matchSearch && matchStatus
|
const matchStatus = statusFilter === 'all' || item.status === statusFilter;
|
||||||
})
|
return matchSearch && matchStatus;
|
||||||
|
});
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteRoadmap.mutateAsync(id)
|
await deleteRoadmap.mutateAsync(id);
|
||||||
toast.success('Roadmap berhasil dihapus')
|
toast.success('Roadmap berhasil dihapus');
|
||||||
setDeletingId(null)
|
setDeletingId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
toast.error('Gagal menghapus roadmap')
|
toast.error('Gagal menghapus roadmap');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const statusColors: Record<TRoadmapStatus, string> = {
|
const statusVariants: Record<
|
||||||
upcoming: 'bg-warning-200 text-warning-700',
|
TRoadmapStatus,
|
||||||
in_progress: 'bg-primary-200 text-primary-700',
|
'warning' | 'info' | 'success'
|
||||||
completed: 'bg-success-200 text-success-500',
|
> = {
|
||||||
}
|
upcoming: 'warning',
|
||||||
|
in_progress: 'info',
|
||||||
|
completed: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
const statusText: Record<TRoadmapStatus, string> = {
|
const statusText: Record<TRoadmapStatus, string> = {
|
||||||
upcoming: 'Upcoming',
|
upcoming: 'Upcoming',
|
||||||
in_progress: 'In Progress',
|
in_progress: 'In Progress',
|
||||||
completed: 'Completed',
|
completed: 'Completed',
|
||||||
}
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<TRoadmapListItem>[] = [
|
const columns: ColumnDef<TRoadmapListItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
meta: { cellClassName: cn('w-20') },
|
meta: { cellClassName: cn('w-10') },
|
||||||
header: ({ table }) => (
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
<input
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
|
{ id: 'title', header: 'Title', accessorKey: 'title' },
|
||||||
{
|
{
|
||||||
id: 'description',
|
id: 'description',
|
||||||
header: 'Description',
|
header: 'Description',
|
||||||
accessorKey: 'description',
|
accessorKey: 'description',
|
||||||
cell: ({ row }) => (
|
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',
|
id: 'status',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const status = row.original.status
|
<Badge variant={statusVariants[row.original.status] ?? 'secondary'}>
|
||||||
return (
|
{statusText[row.original.status] ?? row.original.status}
|
||||||
<div className={`py-2 px-4 rounded-md text-center ${statusColors[status] ?? 'bg-neutral-200 text-neutral-700'}`}>
|
</Badge>
|
||||||
{statusText[status] ?? status}
|
),
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'votes',
|
|
||||||
header: 'Votes',
|
|
||||||
accessorKey: 'votes',
|
|
||||||
},
|
},
|
||||||
|
{ id: 'votes', header: 'Votes', accessorKey: 'votes' },
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
meta: { cellClassName: cn('w-72') },
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex gap-[8px]">
|
<div className="flex items-center gap-2">
|
||||||
{deletingId === row.original.id ? (
|
|
||||||
<>
|
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
handleDelete(row.original.id)
|
navigate({
|
||||||
|
to: '/roadmap-dimentorin/$id',
|
||||||
|
params: { id: row.original.id },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
Konfirmasi
|
<Pencil className="size-3.5" />
|
||||||
</Button>
|
Edit
|
||||||
<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>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setDeletingId(row.original.id)
|
setDeletingId(row.original.id);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<DeleteOutlined /> Delete
|
<Trash2 className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: filteredItems,
|
data: filteredItems,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -184,51 +164,64 @@ function RoadmapDimentorinPage(): React.ReactElement {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(filteredItems.length / pagination.pageSize),
|
pageCount: Math.ceil(filteredItems.length / pagination.pageSize),
|
||||||
manualPagination: false,
|
manualPagination: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="Dimentorin.dev">
|
<BackofficeWrapper
|
||||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Content & Roadmap</h1>
|
title="Content & Roadmap"
|
||||||
|
description="Kelola AI roadmap dimentorin"
|
||||||
<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
|
<Card>
|
||||||
</Button>
|
<CardHeader>
|
||||||
</div>
|
<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="flex justify-between items-center gap-5 mb-2">
|
<div className="relative w-full sm:max-w-sm">
|
||||||
<div className="relative w-full">
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Cari berdasarkan judul roadmap"
|
className="pl-9"
|
||||||
className="pl-12 w-full max-h-full"
|
placeholder="Cari judul roadmap…"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
<SelectTrigger className="w-40">
|
||||||
<option value="">Semua Status</option>
|
<SelectValue placeholder="Status" />
|
||||||
<option value="upcoming">Upcoming</option>
|
</SelectTrigger>
|
||||||
<option value="in_progress">In Progress</option>
|
<SelectContent>
|
||||||
<option value="completed">Completed</option>
|
<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>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
onClick={() => navigate({ to: '/roadmap-dimentorin/create' })}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Buat Roadmap
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{isLoading ? (
|
{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={filteredItems} columns={columns} table={table} />
|
<DataTable data={filteredItems} columns={columns} table={table} />
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={!!deletingId}
|
||||||
|
onOpenChange={(o) => !o && setDeletingId(null)}
|
||||||
|
onConfirm={() => deletingId && handleDelete(deletingId)}
|
||||||
|
title="Hapus roadmap ini?"
|
||||||
|
/>
|
||||||
</BackofficeWrapper>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { Fragment, useState } from 'react'
|
import * as React from 'react';
|
||||||
|
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
Button,
|
||||||
EditOutlined,
|
Card,
|
||||||
DeleteOutlined,
|
CardContent,
|
||||||
PlusOutlined,
|
CardHeader,
|
||||||
} from '@ant-design/icons'
|
Input,
|
||||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
|
import {
|
||||||
|
DataTable,
|
||||||
|
BackofficeWrapper,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -15,144 +19,98 @@ import {
|
|||||||
PaginationState,
|
PaginationState,
|
||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
useRoleList,
|
useRoleList,
|
||||||
useDeleteRole,
|
useDeleteRole,
|
||||||
TRolesListItem,
|
TRolesListItem,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
import React from 'react'
|
import { toast } from 'sonner';
|
||||||
import { toast } from 'sonner'
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
DeleteConfirmDialog,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/roles')({
|
export const Route = createFileRoute('/_authenticated/roles')({
|
||||||
component: RolesPage,
|
component: RolesPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function RolesPage() {
|
function RolesPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = React.useState('');
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
|
|
||||||
const { data: rolesData, isLoading } = useRoleList({
|
const { data: rolesData, isLoading } = useRoleList({
|
||||||
search,
|
search,
|
||||||
page: pagination.pageIndex + 1,
|
page: pagination.pageIndex + 1,
|
||||||
per_page: pagination.pageSize,
|
per_page: pagination.pageSize,
|
||||||
})
|
});
|
||||||
const deleteRole = useDeleteRole()
|
const deleteRole = useDeleteRole();
|
||||||
|
|
||||||
const roles: TRolesListItem[] = rolesData?.data ?? []
|
const roles: TRolesListItem[] = rolesData?.data ?? [];
|
||||||
const totalItems = rolesData?.meta?.total ?? roles.length
|
const totalItems = rolesData?.meta?.total ?? roles.length;
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteRole.mutateAsync(id)
|
await deleteRole.mutateAsync(id);
|
||||||
toast.success('Data role berhasil dihapus')
|
toast.success('Data role berhasil dihapus');
|
||||||
setDeleteId(null)
|
setDeleteId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
toast.error('Data role gagal dihapus')
|
toast.error('Data role gagal dihapus');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<TRolesListItem>[] = [
|
const columns: ColumnDef<TRolesListItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
header: ({ table }) => (
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
<input
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
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: 'ID', accessorKey: 'id' },
|
||||||
|
{ header: 'Roles Name', accessorKey: 'name' },
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex gap-[8px]">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
navigate({ to: '/roles/$id', params: { id: row.original.id } })
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<EditOutlined /> Update
|
|
||||||
</Button>
|
|
||||||
{deleteId === row.original.id ? (
|
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setDeleteId(null)
|
navigate({ to: '/roles/$id', params: { id: row.original.id } });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Batal
|
<Pencil className="size-3.5" />
|
||||||
|
Update
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setDeleteId(row.original.id)
|
setDeleteId(row.original.id);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<DeleteOutlined /> Delete
|
<Trash2 className="size-3.5" />
|
||||||
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: roles,
|
data: roles,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -160,53 +118,58 @@ function RolesPage() {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<BackofficeWrapper title="Roles" description="Kelola role dan akses">
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
<Card>
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
<CardHeader>
|
||||||
<h1 className="text-p2 font-semibold">Roles</h1>
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
</header>
|
<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" />
|
||||||
<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">
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Cari berdasarkan nama roles"
|
placeholder="Cari nama role…"
|
||||||
className="pl-12 w-full max-h-full"
|
className="pl-9"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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>
|
|
||||||
<div className="relative">
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
className="flex gap-3 text-nowrap"
|
|
||||||
onClick={() => navigate({ to: '/roles/create' })}
|
onClick={() => navigate({ to: '/roles/create' })}
|
||||||
|
size="md"
|
||||||
>
|
>
|
||||||
<PlusOutlined />
|
<Plus className="size-4" />
|
||||||
Tambah Role
|
Tambah Role
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{isLoading ? (
|
{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
|
<DataTable
|
||||||
data={roles}
|
data={roles}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
pageSize={9}
|
|
||||||
table={table}
|
table={table}
|
||||||
|
manualPagination
|
||||||
|
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||||
|
currentPage={pagination.pageIndex + 1}
|
||||||
|
onPageChange={(p) =>
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardContent>
|
||||||
</main>
|
</Card>
|
||||||
</Fragment>
|
|
||||||
)
|
<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 { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { SearchOutlined } from '@ant-design/icons'
|
import * as React from 'react';
|
||||||
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms'
|
import { Search, Eye } from 'lucide-react';
|
||||||
import { BackofficeWrapper, DataTable } from '@imphnen-frontend-service/ui/organisms'
|
import {
|
||||||
import { cn } from '@imphnen-frontend-service/utils'
|
Badge,
|
||||||
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from '@tanstack/react-table'
|
Button,
|
||||||
import { ReactElement, useState } from 'react'
|
Card,
|
||||||
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service'
|
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')({
|
export const Route = createFileRoute('/_authenticated/session-dimentorin')({
|
||||||
component: SessionDimentorinPage,
|
component: SessionDimentorinPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function SessionDimentorinPage(): ReactElement {
|
function SessionDimentorinPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [statusFilter, setStatusFilter] = useState('')
|
const [statusFilter, setStatusFilter] = React.useState('all');
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: sessionsData, isLoading } = useMySessions(
|
const { data: sessionsData, isLoading } = useMySessions(
|
||||||
statusFilter ? { status: statusFilter } : undefined
|
statusFilter !== 'all' ? { status: statusFilter } : undefined
|
||||||
)
|
);
|
||||||
|
|
||||||
const sessions: TSessionListItem[] = sessionsData?.sessions ?? []
|
const sessions: TSessionListItem[] = sessionsData?.sessions ?? [];
|
||||||
const totalItems = sessionsData?.total ?? sessions.length
|
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>[] = [
|
const columns: ColumnDef<TSessionListItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
meta: { cellClassName: cn('w-20') },
|
meta: { cellClassName: cn('w-10') },
|
||||||
header: ({ table }) => (
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
<input
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
|
{ id: 'id', header: 'ID Sesi', accessorKey: 'id' },
|
||||||
|
{ id: 'mentorId', header: 'Nama Mentor', accessorKey: 'mentor_id' },
|
||||||
{
|
{
|
||||||
id: 'menteeName',
|
id: 'menteeName',
|
||||||
header: 'Nama Mentee',
|
header: 'Nama Mentee',
|
||||||
@@ -69,55 +87,49 @@ function SessionDimentorinPage(): ReactElement {
|
|||||||
header: 'Waktu',
|
header: 'Waktu',
|
||||||
accessorKey: 'scheduled_at',
|
accessorKey: 'scheduled_at',
|
||||||
cell: ({ row }) => (
|
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',
|
id: 'status',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const status = row.original.status
|
<Badge
|
||||||
const statusColors: Record<string, string> = {
|
variant={statusVariants[row.original.status] ?? 'secondary'}
|
||||||
pending: 'bg-warning-200 text-warning-700',
|
className="capitalize"
|
||||||
confirmed: 'bg-primary-200 text-primary-700',
|
>
|
||||||
ongoing: 'bg-warning-200 text-warning-700',
|
{row.original.status}
|
||||||
completed: 'bg-success-200 text-success-500',
|
</Badge>
|
||||||
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>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
meta: { cellClassName: cn('w-52') },
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
navigate({ to: '/session-dimentorin/$id', params: { id: row.original.id } })
|
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>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: sessions,
|
data: sessions,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -125,39 +137,55 @@ function SessionDimentorinPage(): ReactElement {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="Dimentorin.dev">
|
<BackofficeWrapper
|
||||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Session Management</h1>
|
title="Session Management"
|
||||||
|
description="Kelola sesi mentoring aktif"
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
>
|
||||||
<div className="flex justify-between items-center gap-5 mb-2">
|
<Card>
|
||||||
<div className="relative w-full">
|
<CardHeader>
|
||||||
<Input
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
placeholder="Cari berdasarkan nama lengkap"
|
<div className="relative w-full sm:max-w-sm">
|
||||||
className="pl-12 w-full max-h-full"
|
<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 className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
|
||||||
<SearchOutlined />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
<SelectTrigger className="w-40">
|
||||||
<option value="">Semua Status</option>
|
<SelectValue placeholder="Status" />
|
||||||
<option value="pending">Pending</option>
|
</SelectTrigger>
|
||||||
<option value="confirmed">Confirmed</option>
|
<SelectContent>
|
||||||
<option value="ongoing">On Going</option>
|
<SelectItem value="all">Semua Status</SelectItem>
|
||||||
<option value="completed">Completed</option>
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
<option value="cancelled">Cancelled</option>
|
<SelectItem value="confirmed">Confirmed</SelectItem>
|
||||||
|
<SelectItem value="ongoing">On Going</SelectItem>
|
||||||
|
<SelectItem value="completed">Completed</SelectItem>
|
||||||
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{isLoading ? (
|
{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={sessions} columns={columns} table={table} />
|
<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 }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</BackofficeWrapper>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
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 { cn, For } from '@imphnen-frontend-service/utils'
|
||||||
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service'
|
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service'
|
||||||
|
|
||||||
|
|||||||
@@ -1,58 +1,74 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms'
|
import * as React from 'react';
|
||||||
import { cn, For } from '@imphnen-frontend-service/utils'
|
import {
|
||||||
import { useState } from 'react'
|
Card,
|
||||||
import { GeneralSettings } from './_components/settings-dimentorin/general'
|
CardContent,
|
||||||
import { UserRolesPermission } from './_components/settings-dimentorin/user-roles-permission'
|
Tabs,
|
||||||
import { NotificationSettings } from './_components/settings-dimentorin/notification'
|
TabsContent,
|
||||||
import { SecuritySettings } from './_components/settings-dimentorin/security'
|
TabsList,
|
||||||
import { PaymentSettings } from './_components/settings-dimentorin/payment'
|
TabsTrigger,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
const TABS = {
|
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||||
general: 'General Settings',
|
import { GeneralSettings } from './_components/settings-dimentorin/general';
|
||||||
userRolePermissions: 'User Roles & Permissions',
|
import { UserRolesPermission } from './_components/settings-dimentorin/user-roles-permission';
|
||||||
notification: 'Notification Settings',
|
import { NotificationSettings } from './_components/settings-dimentorin/notification';
|
||||||
security: 'Security',
|
import { SecuritySettings } from './_components/settings-dimentorin/security';
|
||||||
payment: 'Payment',
|
import { PaymentSettings } from './_components/settings-dimentorin/payment';
|
||||||
} as const
|
|
||||||
type Tabs = typeof TABS[keyof typeof TABS]
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/settings-dimentorin')({
|
export const Route = createFileRoute('/_authenticated/settings-dimentorin')({
|
||||||
component: SettingsDimentorinPage,
|
component: SettingsDimentorinPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function SettingsDimentorinPage(): React.ReactElement {
|
function SettingsDimentorinPage(): React.ReactElement {
|
||||||
const [activeTab, setActiveTab] = useState<Tabs>(TABS.general)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="Dimentorin.dev">
|
<BackofficeWrapper
|
||||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Settings</h1>
|
title="Dimentorin Settings"
|
||||||
|
description="Konfigurasi platform Dimentorin.dev"
|
||||||
<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}
|
<Tabs defaultValue="general" className="gap-6">
|
||||||
</button>
|
<TabsList className="flex-wrap">
|
||||||
)}
|
<TabsTrigger value="general">General</TabsTrigger>
|
||||||
</For>
|
<TabsTrigger value="roles">Roles & Permissions</TabsTrigger>
|
||||||
</div>
|
<TabsTrigger value="notification">Notification</TabsTrigger>
|
||||||
<div className="bg-white px-8 py-6 shadow space-y-2 rounded-md flex-1">
|
<TabsTrigger value="security">Security</TabsTrigger>
|
||||||
{activeTab === TABS.general && <GeneralSettings />}
|
<TabsTrigger value="payment">Payment</TabsTrigger>
|
||||||
{activeTab === TABS.userRolePermissions && <UserRolesPermission />}
|
</TabsList>
|
||||||
{activeTab === TABS.notification && <NotificationSettings />}
|
<TabsContent value="general">
|
||||||
{activeTab === TABS.security && <SecuritySettings />}
|
<Card>
|
||||||
{activeTab === TABS.payment && <PaymentSettings />}
|
<CardContent className="pt-6">
|
||||||
</div>
|
<GeneralSettings />
|
||||||
</div>
|
</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>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import * as React from 'react'
|
import * as React from 'react';
|
||||||
import { FC, Fragment, ReactElement, useState } from 'react'
|
import { Filter as FilterIcon, Search, ClipboardCheck } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
FilterOutlined,
|
Badge,
|
||||||
SearchOutlined,
|
Button,
|
||||||
AuditOutlined,
|
Card,
|
||||||
} from '@ant-design/icons'
|
CardContent,
|
||||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
CardHeader,
|
||||||
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms'
|
Input,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
Filter,
|
||||||
|
BackofficeWrapper,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -15,19 +24,25 @@ import {
|
|||||||
PaginationState,
|
PaginationState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table';
|
||||||
import ModalValidate from './_components/transactions/modal-validate'
|
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 {
|
interface Transaction {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
transactionNumber: string
|
transactionNumber: string;
|
||||||
status: TransactionStatus
|
status: TransactionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockTransactions: Transaction[] = Array.from({ length: 20 }, (_, i) => ({
|
const mockTransactions: Transaction[] = Array.from(
|
||||||
|
{ length: 20 },
|
||||||
|
(_, i) => ({
|
||||||
id: i + 1,
|
id: i + 1,
|
||||||
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
|
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
|
||||||
transactionNumber: '25D2133Y9AFYBD',
|
transactionNumber: '25D2133Y9AFYBD',
|
||||||
@@ -36,110 +51,84 @@ const mockTransactions: Transaction[] = Array.from({ length: 20 }, (_, i) => ({
|
|||||||
: i % 5 === 0
|
: i % 5 === 0
|
||||||
? 'unchecked'
|
? 'unchecked'
|
||||||
: 'valid') as TransactionStatus,
|
: 'valid') as TransactionStatus,
|
||||||
}))
|
})
|
||||||
|
);
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/transactions')({
|
export const Route = createFileRoute('/_authenticated/transactions')({
|
||||||
component: TransactionsPage,
|
component: TransactionsPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function TransactionsPage() {
|
function TransactionsPage() {
|
||||||
const [showModalValidate, setShowModalValidate] = useState(false)
|
const [showModalValidate, setShowModalValidate] = React.useState(false);
|
||||||
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
const [showFilter, setShowFilter] = useState(false)
|
const [showFilter, setShowFilter] = React.useState(false);
|
||||||
|
|
||||||
const validationOptions = [
|
const validationOptions = [
|
||||||
{ id: 'option1', value: 'unchecked', label: 'Unchecked' },
|
{ id: 'unchecked', value: 'unchecked', label: 'Unchecked' },
|
||||||
{ id: 'option2', value: 'valid', label: 'Valid' },
|
{ id: 'valid', value: 'valid', label: 'Valid' },
|
||||||
{ id: 'option3', value: 'invalid', label: 'Invalid' },
|
{ id: 'invalid', value: 'invalid', label: 'Invalid' },
|
||||||
]
|
];
|
||||||
|
|
||||||
|
const statusVariants: Record<
|
||||||
|
TransactionStatus,
|
||||||
|
'success' | 'destructive' | 'warning'
|
||||||
|
> = {
|
||||||
|
valid: 'success',
|
||||||
|
invalid: 'destructive',
|
||||||
|
unchecked: 'warning',
|
||||||
|
};
|
||||||
|
|
||||||
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: '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> = {
|
const statusText: Record<TransactionStatus, string> = {
|
||||||
valid: 'Valid',
|
valid: 'Valid',
|
||||||
invalid: 'Invalid',
|
invalid: 'Invalid',
|
||||||
unchecked: 'Unchecked',
|
unchecked: 'Unchecked',
|
||||||
}
|
};
|
||||||
return (
|
|
||||||
<div
|
const columns: ColumnDef<Transaction>[] = [
|
||||||
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
{
|
||||||
>
|
id: 'select',
|
||||||
{statusText[status]}
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
</div>
|
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 }) => (
|
||||||
|
<Badge variant={statusVariants[row.original.status]}>
|
||||||
|
{statusText[row.original.status]}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
cell: ({ row }) => (
|
cell: () => (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setShowModalValidate(true)
|
setShowModalValidate(true);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 w-full"
|
|
||||||
>
|
>
|
||||||
<AuditOutlined className="text-[16px]" /> Update
|
<ClipboardCheck className="size-3.5" />
|
||||||
|
Update
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: mockTransactions,
|
data: mockTransactions,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -147,64 +136,58 @@ function TransactionsPage() {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(mockTransactions.length / pagination.pageSize),
|
pageCount: Math.ceil(mockTransactions.length / pagination.pageSize),
|
||||||
manualPagination: false,
|
manualPagination: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<BackofficeWrapper
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
title="Validasi Transaksi"
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
description="Verifikasi status transaksi pengguna"
|
||||||
<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">
|
|
||||||
<Input
|
|
||||||
placeholder="Cari berdasarkan nama lengkap, nomor order Shopee"
|
|
||||||
className="pl-12 w-full max-h-full"
|
|
||||||
/>
|
|
||||||
<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 />
|
<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 nomor order…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Popover open={showFilter} onOpenChange={setShowFilter}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="secondary" size="md">
|
||||||
|
<FilterIcon className="size-4" />
|
||||||
Filters
|
Filters
|
||||||
</Button>
|
</Button>
|
||||||
{showFilter && (
|
</PopoverTrigger>
|
||||||
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
|
<PopoverContent align="end" className="w-auto p-0">
|
||||||
<Filter
|
<Filter
|
||||||
options={validationOptions}
|
options={validationOptions}
|
||||||
|
title="Status"
|
||||||
onClose={() => setShowFilter(false)}
|
onClose={() => setShowFilter(false)}
|
||||||
onFilterChange={(value) => {
|
onFilterChange={(value) => {
|
||||||
console.log('Selected filter:', value)
|
console.log('Selected filter:', value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardHeader>
|
||||||
</div>
|
<CardContent>
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable data={mockTransactions} columns={columns} table={table} />
|
<DataTable data={mockTransactions} columns={columns} table={table} />
|
||||||
</section>
|
</CardContent>
|
||||||
</main>
|
</Card>
|
||||||
|
|
||||||
<ModalValidate
|
<ModalValidate
|
||||||
isOpen={showModalValidate}
|
isOpen={showModalValidate}
|
||||||
onClose={() => setShowModalValidate(false)}
|
onClose={() => setShowModalValidate(false)}
|
||||||
handleValid={() => {
|
handleValid={() => {
|
||||||
console.log('Action ketika user klik Valid')
|
console.log('Action ketika user klik Valid');
|
||||||
}}
|
}}
|
||||||
handleInvalid={() => {
|
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 { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { DeleteOutlined, SearchOutlined } from '@ant-design/icons'
|
import * as React from 'react';
|
||||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
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 {
|
import {
|
||||||
BackofficeWrapper,
|
BackofficeWrapper,
|
||||||
DataTable,
|
DataTable,
|
||||||
} from '@imphnen-frontend-service/ui/organisms'
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import { cn, For } from '@imphnen-frontend-service/utils'
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -13,98 +25,85 @@ import {
|
|||||||
PaginationState,
|
PaginationState,
|
||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table';
|
||||||
import { ReactElement, useState } from 'react'
|
import { toast } from 'sonner';
|
||||||
import { toast } from 'sonner'
|
|
||||||
import {
|
import {
|
||||||
useMentorList,
|
useMentorList,
|
||||||
useUserList,
|
useUserList,
|
||||||
useDeleteMentor,
|
useDeleteMentor,
|
||||||
MentorDetailResponseDto,
|
MentorDetailResponseDto,
|
||||||
TUsersListItem,
|
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')({
|
export const Route = createFileRoute('/_authenticated/users-dimentorin')({
|
||||||
component: UsersDimentorinPage,
|
component: UsersDimentorinPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function UsersDimentorinPage(): ReactElement {
|
function UsersDimentorinPage() {
|
||||||
const TABS = ['mentor', 'mentee'] as const
|
const navigate = useNavigate();
|
||||||
const navigate = useNavigate()
|
const [activeTab, setActiveTab] = React.useState<'mentor' | 'mentee'>(
|
||||||
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor')
|
'mentor'
|
||||||
const [search, setSearch] = useState('')
|
);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
const [search, setSearch] = React.useState('');
|
||||||
|
const [deletingId, setDeletingId] = React.useState<string | null>(null);
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] =
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
React.useState<RowSelectionState>({});
|
||||||
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: mentorData, isLoading: mentorLoading } = useMentorList({
|
const { data: mentorData, isLoading: mentorLoading } = useMentorList({
|
||||||
search,
|
search,
|
||||||
page: pagination.pageIndex + 1,
|
page: pagination.pageIndex + 1,
|
||||||
per_page: pagination.pageSize,
|
per_page: pagination.pageSize,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: menteeData, isLoading: menteeLoading } = useUserList({
|
const { data: menteeData, isLoading: menteeLoading } = useUserList({
|
||||||
search,
|
search,
|
||||||
page: pagination.pageIndex + 1,
|
page: pagination.pageIndex + 1,
|
||||||
per_page: pagination.pageSize,
|
per_page: pagination.pageSize,
|
||||||
})
|
});
|
||||||
|
const deleteMentor = useDeleteMentor();
|
||||||
|
|
||||||
const deleteMentor = useDeleteMentor()
|
const mentors: MentorDetailResponseDto[] = mentorData?.data ?? [];
|
||||||
|
const mentees: TUsersListItem[] = menteeData?.data ?? [];
|
||||||
const mentors: MentorDetailResponseDto[] = mentorData?.data ?? []
|
const mentorTotal = mentorData?.meta?.total ?? mentors.length;
|
||||||
const mentees: TUsersListItem[] = menteeData?.data ?? []
|
const menteeTotal = menteeData?.meta?.total ?? mentees.length;
|
||||||
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 handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteMentor.mutateAsync(id)
|
await deleteMentor.mutateAsync(id);
|
||||||
toast.success('Akun berhasil dihapus')
|
toast.success('Akun berhasil dihapus');
|
||||||
setDeletingId(null)
|
setDeletingId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
toast.error('Gagal menghapus akun')
|
toast.error('Gagal menghapus akun');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusVariantMap: Record<
|
||||||
|
string,
|
||||||
|
'success' | 'warning' | 'destructive' | 'secondary'
|
||||||
|
> = {
|
||||||
|
active: 'success',
|
||||||
|
pending: 'warning',
|
||||||
|
inactive: 'destructive',
|
||||||
|
};
|
||||||
|
|
||||||
const mentorColumns: ColumnDef<MentorDetailResponseDto>[] = [
|
const mentorColumns: ColumnDef<MentorDetailResponseDto>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
meta: { cellClassName: cn('w-20') },
|
meta: { cellClassName: cn('w-10') },
|
||||||
header: ({ table }) => (
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
<input
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
|
{ id: 'name', header: 'Name', accessorKey: 'fullname' },
|
||||||
|
{ id: 'email', header: 'Email', accessorKey: 'email' },
|
||||||
{
|
{
|
||||||
id: 'rating',
|
id: 'rating',
|
||||||
header: 'Rating',
|
header: 'Rating',
|
||||||
@@ -116,139 +115,86 @@ function UsersDimentorinPage(): ReactElement {
|
|||||||
header: 'Status',
|
header: 'Status',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const status = row.original.status
|
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',
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className={`py-2 px-4 rounded-md text-center capitalize ${statusColors[status] ?? 'bg-neutral-200 text-neutral-700'}`}>
|
<Badge variant={statusVariantMap[status] ?? 'secondary'} className="capitalize">
|
||||||
{status}
|
{status || 'unknown'}
|
||||||
</div>
|
</Badge>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
meta: { cellClassName: cn('w-72') },
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex gap-[8px]">
|
<div className="flex items-center gap-2">
|
||||||
{deletingId === row.original.id ? (
|
|
||||||
<>
|
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
handleDelete(row.original.id)
|
navigate({
|
||||||
|
to: '/users-dimentorin/$id',
|
||||||
|
params: { id: row.original.id },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
Konfirmasi
|
<Eye className="size-3.5" />
|
||||||
</Button>
|
Detail
|
||||||
<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>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setDeletingId(row.original.id)
|
setDeletingId(row.original.id);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<DeleteOutlined />
|
<Trash2 className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const menteeColumns: ColumnDef<TUsersListItem>[] = [
|
const menteeColumns: ColumnDef<TUsersListItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
meta: { cellClassName: cn('w-20') },
|
meta: { cellClassName: cn('w-10') },
|
||||||
header: ({ table }) => (
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
<input
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
|
{ id: 'name', header: 'Name', accessorKey: 'fullname' },
|
||||||
|
{ id: 'email', header: 'Email', accessorKey: 'email' },
|
||||||
{
|
{
|
||||||
id: 'status',
|
id: 'status',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
accessorKey: 'is_active',
|
accessorKey: 'is_active',
|
||||||
cell: ({ row }) => (
|
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'}
|
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||||
</div>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
meta: { cellClassName: cn('w-72') },
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
navigate({ to: '/users-dimentorin/$id', params: { id: row.original.id } })
|
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>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const mentorTable = useReactTable({
|
const mentorTable = useReactTable({
|
||||||
data: mentors,
|
data: mentors,
|
||||||
@@ -261,7 +207,7 @@ function UsersDimentorinPage(): ReactElement {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(mentorTotal / pagination.pageSize),
|
pageCount: Math.ceil(mentorTotal / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
const menteeTable = useReactTable({
|
const menteeTable = useReactTable({
|
||||||
data: mentees,
|
data: mentees,
|
||||||
@@ -274,59 +220,91 @@ function UsersDimentorinPage(): ReactElement {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(menteeTotal / pagination.pageSize),
|
pageCount: Math.ceil(menteeTotal / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="Dimentorin.dev">
|
<BackofficeWrapper
|
||||||
<div className="mb-8 flex justify-between items-center">
|
title="Users Dimentorin"
|
||||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">
|
description="Manajemen mentor dan mentee"
|
||||||
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}
|
<Card>
|
||||||
</Button>
|
<CardHeader>
|
||||||
)}
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
</For>
|
<div className="relative w-full sm:max-w-sm">
|
||||||
</div>
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
</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
|
<Input
|
||||||
placeholder="Cari berdasarkan nama lengkap"
|
className="pl-9"
|
||||||
className="pl-12 w-full max-h-full"
|
placeholder="Cari nama lengkap…"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{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} />
|
<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 }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
||||||
|
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={!!deletingId}
|
||||||
|
onOpenChange={(o) => !o && setDeletingId(null)}
|
||||||
|
onConfirm={() => deletingId && handleDelete(deletingId)}
|
||||||
|
title="Hapus akun mentor ini?"
|
||||||
|
/>
|
||||||
</BackofficeWrapper>
|
</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 { SessionToken } from '@imphnen-frontend-service/service'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({
|
export const Route = createFileRoute('/')({
|
||||||
beforeLoad: () => {
|
component: IndexRedirect,
|
||||||
|
})
|
||||||
|
|
||||||
|
function IndexRedirect() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
const session = SessionToken.get()
|
const session = SessionToken.get()
|
||||||
if (session?.token?.access_token) {
|
if (session?.token?.access_token) {
|
||||||
throw redirect({ to: '/hackathon-dashboard' })
|
navigate({ to: '/hackathon-dashboard' })
|
||||||
|
} else {
|
||||||
|
navigate({ to: '/auth/login' })
|
||||||
}
|
}
|
||||||
throw redirect({ 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,
|
port: 3001,
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
},
|
},
|
||||||
build: { rolldownOptions: { output: { codeSplitting: { groups: [{ name: 'vendor', test: /node_modules/ }] } } } },
|
resolve: {
|
||||||
|
dedupe: ['react', 'react-dom', '@tanstack/react-router', '@tanstack/react-query'],
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
TanStackRouterVite({
|
TanStackRouterVite({
|
||||||
routeFileIgnorePattern: '_components|_hooks|_hook|_data',
|
routeFileIgnorePattern: '_components|_hooks|_hook|_data',
|
||||||
@@ -32,6 +34,9 @@ export default defineConfig(() => ({
|
|||||||
commonjsOptions: {
|
commonjsOptions: {
|
||||||
transformMixedEsModules: true,
|
transformMixedEsModules: true,
|
||||||
},
|
},
|
||||||
|
rolldownOptions: {
|
||||||
|
output: { codeSplitting: { groups: [{ name: 'vendor', test: /node_modules/ }] } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
watch: false,
|
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-p2: 1.23rem; /* ~19.68px */
|
||||||
--text-label1: 0.98rem; /* ~15.74px */
|
--text-label1: 0.98rem; /* ~15.74px */
|
||||||
--text-label2: 0.78rem; /* ~12.59px */
|
--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 {
|
@layer base {
|
||||||
@@ -124,4 +140,8 @@
|
|||||||
.scrollbar-hide::-webkit-scrollbar {
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
@apply hidden;
|
@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 SiteMentoringRouteImport } from './routes/_site/mentoring'
|
||||||
import { Route as SiteArticlesRouteImport } from './routes/_site/articles'
|
import { Route as SiteArticlesRouteImport } from './routes/_site/articles'
|
||||||
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
|
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 SiteProfileIdRouteImport } from './routes/_site/profile_/$id'
|
||||||
import { Route as SiteMentoringIdRouteImport } from './routes/_site/mentoring_/$id'
|
import { Route as SiteMentoringIdRouteImport } from './routes/_site/mentoring_/$id'
|
||||||
import { Route as SiteArticlesSlugRouteImport } from './routes/_site/articles_/$slug'
|
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 PublicAuthGoogleCallbackRouteImport } from './routes/_public/auth/google-callback'
|
||||||
import { Route as PublicAuthForgotRouteImport } from './routes/_public/auth/forgot'
|
import { Route as PublicAuthForgotRouteImport } from './routes/_public/auth/forgot'
|
||||||
import { Route as AuthenticatedDashboardSettingsRouteImport } from './routes/_authenticated/dashboard_/settings'
|
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 PublicAuthRegisterSuccessRouteImport } from './routes/_public/auth/register_/success'
|
||||||
import { Route as PublicAuthRegisterOtpRouteImport } from './routes/_public/auth/register_/otp'
|
import { Route as PublicAuthRegisterOtpRouteImport } from './routes/_public/auth/register_/otp'
|
||||||
import { Route as PublicAuthRegisterMentorSuccessRouteImport } from './routes/_public/auth/register-mentor_/success'
|
import { Route as PublicAuthRegisterMentorSuccessRouteImport } from './routes/_public/auth/register-mentor_/success'
|
||||||
@@ -80,9 +84,9 @@ const AuthenticatedDashboardRoute = AuthenticatedDashboardRouteImport.update({
|
|||||||
} as any)
|
} as any)
|
||||||
const AuthenticatedDashboardIndexRoute =
|
const AuthenticatedDashboardIndexRoute =
|
||||||
AuthenticatedDashboardIndexRouteImport.update({
|
AuthenticatedDashboardIndexRouteImport.update({
|
||||||
id: '/dashboard_/',
|
id: '/',
|
||||||
path: '/dashboard/',
|
path: '/',
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
getParentRoute: () => AuthenticatedDashboardRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const SiteProfileIdRoute = SiteProfileIdRouteImport.update({
|
const SiteProfileIdRoute = SiteProfileIdRouteImport.update({
|
||||||
id: '/profile_/$id',
|
id: '/profile_/$id',
|
||||||
@@ -138,6 +142,30 @@ const AuthenticatedDashboardSettingsRoute =
|
|||||||
path: '/dashboard/settings',
|
path: '/dashboard/settings',
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} 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 =
|
const PublicAuthRegisterSuccessRoute =
|
||||||
PublicAuthRegisterSuccessRouteImport.update({
|
PublicAuthRegisterSuccessRouteImport.update({
|
||||||
id: '/auth/register_/success',
|
id: '/auth/register_/success',
|
||||||
@@ -174,11 +202,15 @@ const PublicAuthForgotOtpRoute = PublicAuthForgotOtpRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/dashboard': typeof AuthenticatedDashboardRoute
|
'/dashboard': typeof AuthenticatedDashboardRouteWithChildren
|
||||||
'/articles': typeof SiteArticlesRoute
|
'/articles': typeof SiteArticlesRoute
|
||||||
'/mentoring': typeof SiteMentoringRoute
|
'/mentoring': typeof SiteMentoringRoute
|
||||||
'/profile': typeof SiteProfileRoute
|
'/profile': typeof SiteProfileRoute
|
||||||
'/resources': typeof SiteResourcesRoute
|
'/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
|
'/dashboard/settings': typeof AuthenticatedDashboardSettingsRoute
|
||||||
'/auth/forgot': typeof PublicAuthForgotRoute
|
'/auth/forgot': typeof PublicAuthForgotRoute
|
||||||
'/auth/google-callback': typeof PublicAuthGoogleCallbackRoute
|
'/auth/google-callback': typeof PublicAuthGoogleCallbackRoute
|
||||||
@@ -199,11 +231,14 @@ export interface FileRoutesByFullPath {
|
|||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/dashboard': typeof AuthenticatedDashboardIndexRoute
|
|
||||||
'/articles': typeof SiteArticlesRoute
|
'/articles': typeof SiteArticlesRoute
|
||||||
'/mentoring': typeof SiteMentoringRoute
|
'/mentoring': typeof SiteMentoringRoute
|
||||||
'/profile': typeof SiteProfileRoute
|
'/profile': typeof SiteProfileRoute
|
||||||
'/resources': typeof SiteResourcesRoute
|
'/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
|
'/dashboard/settings': typeof AuthenticatedDashboardSettingsRoute
|
||||||
'/auth/forgot': typeof PublicAuthForgotRoute
|
'/auth/forgot': typeof PublicAuthForgotRoute
|
||||||
'/auth/google-callback': typeof PublicAuthGoogleCallbackRoute
|
'/auth/google-callback': typeof PublicAuthGoogleCallbackRoute
|
||||||
@@ -214,6 +249,7 @@ export interface FileRoutesByTo {
|
|||||||
'/articles/$slug': typeof SiteArticlesSlugRoute
|
'/articles/$slug': typeof SiteArticlesSlugRoute
|
||||||
'/mentoring/$id': typeof SiteMentoringIdRoute
|
'/mentoring/$id': typeof SiteMentoringIdRoute
|
||||||
'/profile/$id': typeof SiteProfileIdRoute
|
'/profile/$id': typeof SiteProfileIdRoute
|
||||||
|
'/dashboard': typeof AuthenticatedDashboardIndexRoute
|
||||||
'/auth/forgot/otp': typeof PublicAuthForgotOtpRoute
|
'/auth/forgot/otp': typeof PublicAuthForgotOtpRoute
|
||||||
'/auth/forgot/summon': typeof PublicAuthForgotSummonRoute
|
'/auth/forgot/summon': typeof PublicAuthForgotSummonRoute
|
||||||
'/auth/register-mentor/pending': typeof PublicAuthRegisterMentorPendingRoute
|
'/auth/register-mentor/pending': typeof PublicAuthRegisterMentorPendingRoute
|
||||||
@@ -227,11 +263,15 @@ export interface FileRoutesById {
|
|||||||
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
||||||
'/_public': typeof PublicRouteWithChildren
|
'/_public': typeof PublicRouteWithChildren
|
||||||
'/_site': typeof SiteRouteWithChildren
|
'/_site': typeof SiteRouteWithChildren
|
||||||
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
|
'/_authenticated/dashboard': typeof AuthenticatedDashboardRouteWithChildren
|
||||||
'/_site/articles': typeof SiteArticlesRoute
|
'/_site/articles': typeof SiteArticlesRoute
|
||||||
'/_site/mentoring': typeof SiteMentoringRoute
|
'/_site/mentoring': typeof SiteMentoringRoute
|
||||||
'/_site/profile': typeof SiteProfileRoute
|
'/_site/profile': typeof SiteProfileRoute
|
||||||
'/_site/resources': typeof SiteResourcesRoute
|
'/_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
|
'/_authenticated/dashboard_/settings': typeof AuthenticatedDashboardSettingsRoute
|
||||||
'/_public/auth/forgot': typeof PublicAuthForgotRoute
|
'/_public/auth/forgot': typeof PublicAuthForgotRoute
|
||||||
'/_public/auth/google-callback': typeof PublicAuthGoogleCallbackRoute
|
'/_public/auth/google-callback': typeof PublicAuthGoogleCallbackRoute
|
||||||
@@ -242,7 +282,7 @@ export interface FileRoutesById {
|
|||||||
'/_site/articles_/$slug': typeof SiteArticlesSlugRoute
|
'/_site/articles_/$slug': typeof SiteArticlesSlugRoute
|
||||||
'/_site/mentoring_/$id': typeof SiteMentoringIdRoute
|
'/_site/mentoring_/$id': typeof SiteMentoringIdRoute
|
||||||
'/_site/profile_/$id': typeof SiteProfileIdRoute
|
'/_site/profile_/$id': typeof SiteProfileIdRoute
|
||||||
'/_authenticated/dashboard_/': typeof AuthenticatedDashboardIndexRoute
|
'/_authenticated/dashboard/': typeof AuthenticatedDashboardIndexRoute
|
||||||
'/_public/auth/forgot_/otp': typeof PublicAuthForgotOtpRoute
|
'/_public/auth/forgot_/otp': typeof PublicAuthForgotOtpRoute
|
||||||
'/_public/auth/forgot_/summon': typeof PublicAuthForgotSummonRoute
|
'/_public/auth/forgot_/summon': typeof PublicAuthForgotSummonRoute
|
||||||
'/_public/auth/register-mentor_/pending': typeof PublicAuthRegisterMentorPendingRoute
|
'/_public/auth/register-mentor_/pending': typeof PublicAuthRegisterMentorPendingRoute
|
||||||
@@ -259,6 +299,10 @@ export interface FileRouteTypes {
|
|||||||
| '/mentoring'
|
| '/mentoring'
|
||||||
| '/profile'
|
| '/profile'
|
||||||
| '/resources'
|
| '/resources'
|
||||||
|
| '/dashboard/article-builder'
|
||||||
|
| '/dashboard/learning-path'
|
||||||
|
| '/dashboard/mentoring'
|
||||||
|
| '/dashboard/roadmap-discovery'
|
||||||
| '/dashboard/settings'
|
| '/dashboard/settings'
|
||||||
| '/auth/forgot'
|
| '/auth/forgot'
|
||||||
| '/auth/google-callback'
|
| '/auth/google-callback'
|
||||||
@@ -279,11 +323,14 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/dashboard'
|
|
||||||
| '/articles'
|
| '/articles'
|
||||||
| '/mentoring'
|
| '/mentoring'
|
||||||
| '/profile'
|
| '/profile'
|
||||||
| '/resources'
|
| '/resources'
|
||||||
|
| '/dashboard/article-builder'
|
||||||
|
| '/dashboard/learning-path'
|
||||||
|
| '/dashboard/mentoring'
|
||||||
|
| '/dashboard/roadmap-discovery'
|
||||||
| '/dashboard/settings'
|
| '/dashboard/settings'
|
||||||
| '/auth/forgot'
|
| '/auth/forgot'
|
||||||
| '/auth/google-callback'
|
| '/auth/google-callback'
|
||||||
@@ -294,6 +341,7 @@ export interface FileRouteTypes {
|
|||||||
| '/articles/$slug'
|
| '/articles/$slug'
|
||||||
| '/mentoring/$id'
|
| '/mentoring/$id'
|
||||||
| '/profile/$id'
|
| '/profile/$id'
|
||||||
|
| '/dashboard'
|
||||||
| '/auth/forgot/otp'
|
| '/auth/forgot/otp'
|
||||||
| '/auth/forgot/summon'
|
| '/auth/forgot/summon'
|
||||||
| '/auth/register-mentor/pending'
|
| '/auth/register-mentor/pending'
|
||||||
@@ -311,6 +359,10 @@ export interface FileRouteTypes {
|
|||||||
| '/_site/mentoring'
|
| '/_site/mentoring'
|
||||||
| '/_site/profile'
|
| '/_site/profile'
|
||||||
| '/_site/resources'
|
| '/_site/resources'
|
||||||
|
| '/_authenticated/dashboard/article-builder'
|
||||||
|
| '/_authenticated/dashboard/learning-path'
|
||||||
|
| '/_authenticated/dashboard/mentoring'
|
||||||
|
| '/_authenticated/dashboard/roadmap-discovery'
|
||||||
| '/_authenticated/dashboard_/settings'
|
| '/_authenticated/dashboard_/settings'
|
||||||
| '/_public/auth/forgot'
|
| '/_public/auth/forgot'
|
||||||
| '/_public/auth/google-callback'
|
| '/_public/auth/google-callback'
|
||||||
@@ -321,7 +373,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_site/articles_/$slug'
|
| '/_site/articles_/$slug'
|
||||||
| '/_site/mentoring_/$id'
|
| '/_site/mentoring_/$id'
|
||||||
| '/_site/profile_/$id'
|
| '/_site/profile_/$id'
|
||||||
| '/_authenticated/dashboard_/'
|
| '/_authenticated/dashboard/'
|
||||||
| '/_public/auth/forgot_/otp'
|
| '/_public/auth/forgot_/otp'
|
||||||
| '/_public/auth/forgot_/summon'
|
| '/_public/auth/forgot_/summon'
|
||||||
| '/_public/auth/register-mentor_/pending'
|
| '/_public/auth/register-mentor_/pending'
|
||||||
@@ -402,12 +454,12 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedDashboardRouteImport
|
preLoaderRoute: typeof AuthenticatedDashboardRouteImport
|
||||||
parentRoute: typeof AuthenticatedRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
'/_authenticated/dashboard_/': {
|
'/_authenticated/dashboard/': {
|
||||||
id: '/_authenticated/dashboard_/'
|
id: '/_authenticated/dashboard/'
|
||||||
path: '/dashboard'
|
path: '/'
|
||||||
fullPath: '/dashboard/'
|
fullPath: '/dashboard/'
|
||||||
preLoaderRoute: typeof AuthenticatedDashboardIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedDashboardIndexRouteImport
|
||||||
parentRoute: typeof AuthenticatedRoute
|
parentRoute: typeof AuthenticatedDashboardRoute
|
||||||
}
|
}
|
||||||
'/_site/profile_/$id': {
|
'/_site/profile_/$id': {
|
||||||
id: '/_site/profile_/$id'
|
id: '/_site/profile_/$id'
|
||||||
@@ -479,6 +531,34 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedDashboardSettingsRouteImport
|
preLoaderRoute: typeof AuthenticatedDashboardSettingsRouteImport
|
||||||
parentRoute: typeof AuthenticatedRoute
|
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': {
|
'/_public/auth/register_/success': {
|
||||||
id: '/_public/auth/register_/success'
|
id: '/_public/auth/register_/success'
|
||||||
path: '/auth/register/success'
|
path: '/auth/register/success'
|
||||||
@@ -524,16 +604,39 @@ declare module '@tanstack/react-router' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthenticatedRouteChildren {
|
interface AuthenticatedDashboardRouteChildren {
|
||||||
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
|
AuthenticatedDashboardArticleBuilderRoute: typeof AuthenticatedDashboardArticleBuilderRoute
|
||||||
AuthenticatedDashboardSettingsRoute: typeof AuthenticatedDashboardSettingsRoute
|
AuthenticatedDashboardLearningPathRoute: typeof AuthenticatedDashboardLearningPathRoute
|
||||||
|
AuthenticatedDashboardMentoringRoute: typeof AuthenticatedDashboardMentoringRoute
|
||||||
|
AuthenticatedDashboardRoadmapDiscoveryRoute: typeof AuthenticatedDashboardRoadmapDiscoveryRoute
|
||||||
AuthenticatedDashboardIndexRoute: typeof AuthenticatedDashboardIndexRoute
|
AuthenticatedDashboardIndexRoute: typeof AuthenticatedDashboardIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
const AuthenticatedDashboardRouteChildren: AuthenticatedDashboardRouteChildren =
|
||||||
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
|
{
|
||||||
AuthenticatedDashboardSettingsRoute: AuthenticatedDashboardSettingsRoute,
|
AuthenticatedDashboardArticleBuilderRoute:
|
||||||
|
AuthenticatedDashboardArticleBuilderRoute,
|
||||||
|
AuthenticatedDashboardLearningPathRoute:
|
||||||
|
AuthenticatedDashboardLearningPathRoute,
|
||||||
|
AuthenticatedDashboardMentoringRoute: AuthenticatedDashboardMentoringRoute,
|
||||||
|
AuthenticatedDashboardRoadmapDiscoveryRoute:
|
||||||
|
AuthenticatedDashboardRoadmapDiscoveryRoute,
|
||||||
AuthenticatedDashboardIndexRoute: AuthenticatedDashboardIndexRoute,
|
AuthenticatedDashboardIndexRoute: AuthenticatedDashboardIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthenticatedDashboardRouteWithChildren =
|
||||||
|
AuthenticatedDashboardRoute._addFileChildren(
|
||||||
|
AuthenticatedDashboardRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface AuthenticatedRouteChildren {
|
||||||
|
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRouteWithChildren
|
||||||
|
AuthenticatedDashboardSettingsRoute: typeof AuthenticatedDashboardSettingsRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||||
|
AuthenticatedDashboardRoute: AuthenticatedDashboardRouteWithChildren,
|
||||||
|
AuthenticatedDashboardSettingsRoute: AuthenticatedDashboardSettingsRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { SessionToken } from '@imphnen-frontend-service/service'
|
|||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated')({
|
export const Route = createFileRoute('/_authenticated')({
|
||||||
beforeLoad: () => {
|
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()
|
const session = SessionToken.get()
|
||||||
if (!session?.token?.access_token) {
|
if (!session?.token?.access_token) {
|
||||||
throw redirect({ to: '/auth/login' })
|
throw redirect({ to: '/auth/login' })
|
||||||
|
|||||||
@@ -1,65 +1,228 @@
|
|||||||
import { createFileRoute, Outlet, Link, useLocation, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, Outlet, Link, useLocation, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
import { useAuthStore } from '@imphnen-frontend-service/service'
|
import { useAuthStore } from '@imphnen-frontend-service/service'
|
||||||
import { Icon } from '@iconify/react'
|
import { Icon } from '@iconify/react'
|
||||||
|
import { resolvePersona } from './dashboard/_data/persona-resolver'
|
||||||
|
|
||||||
const navItems = [
|
interface NavItem {
|
||||||
{ path: '/dashboard', label: 'Dashboard', icon: 'mdi:view-dashboard' },
|
path?: string;
|
||||||
{ path: '/dashboard/settings', label: 'Settings', icon: 'mdi:cog' },
|
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')({
|
export const Route = createFileRoute('/_authenticated/dashboard')({
|
||||||
component: DashboardLayout,
|
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() {
|
function DashboardLayout() {
|
||||||
const { session, clearSession } = useAuthStore()
|
const { session, clearSession } = useAuthStore()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
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-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>
|
||||||
|
|
||||||
|
{/* 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 (
|
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
|
<Link
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={`px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-colors ${
|
className={`${baseClasses} ${activeClasses}`}
|
||||||
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" />
|
<Icon
|
||||||
|
icon={item.icon}
|
||||||
|
width="16"
|
||||||
|
className={isActive ? 'text-white' : 'text-text-muted'}
|
||||||
|
style={isActive ? { color: '#ffffff' } : {}}
|
||||||
|
/>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
);
|
||||||
</div>
|
}
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
return (
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:block">
|
|
||||||
{session?.user?.fullname || session?.user?.email}
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { clearSession(); navigate({ to: '/auth/login' }) }}
|
key={item.label}
|
||||||
className="text-sm text-red-500 hover:text-red-600 cursor-pointer flex items-center gap-1"
|
className={baseClasses}
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
>
|
>
|
||||||
<Icon icon="mdi:logout" width="18" />
|
<Icon icon={item.icon} width="16" />
|
||||||
<span className="hidden sm:inline">Logout</span>
|
{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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
</div>
|
|
||||||
</nav>
|
{/* Main Content Area */}
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<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 />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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