Compare commits

..
10 Commits
Author SHA1 Message Date
Hafid Nur 6d3e2470c8 fix(landing): remove unnecessary line after footer
Nix Build & Deploy / detect (push) Canceled after 0s
Nix Build & Deploy / build (${{ fromJson(needs.detect.outputs.affected) }}) (push) Canceled after 0s
Nix Build & Deploy / deploy (push) Canceled after 0s
2026-04-30 15:45:40 +07:00
Asep Haryana Saputra 48411d3a08 Dashboard users part 2 (#79)
* fix: add FigmaImage directory to .gitignore

* feat: enhance dashboard tests and add new components for rendering

* feat: add navigation functionality and UI enhancements to dashboard components

* feat: add article preview and mentor contact modals, implement article builder and FAQ page

* refactor: remove FAQ route and update settings layout for improved responsiveness

* feat: enhance two-step authentication UI and functionality with improved styles and user feedback

* feat: add mentoring detail and feedback modals, enhance mentoring page functionality and UI

* fix: handle JSON parse failure in edit profile modal error handling
2026-04-15 12:20:05 +07:00
Asep Haryana Saputra 426b71f341 fix: update action versions in Nix build workflow (#78) 2026-04-13 14:28:10 +07:00
Maulana SodiqinandClaude Opus 4.6 06c69cb3c5 fix(backoffice): React 19 read-only property runtime errors
Menambahkan resolve.dedupe untuk react/react-dom/@tanstack/react-router/
@tanstack/react-query di vite.config.ts agar tidak ada duplicate instance
yang di-bundle dari Radix primitives.

Menghapus StrictMode dari main.tsx karena combining StrictMode dengan
recharts 3.x (yang bawa @reduxjs/toolkit + immer autoFreeze) + TanStack
Router 1.168 trigger "Cannot assign to read only property 'lanes' of
#<li>" dan "pendingBuiltLocation of #<zr>" saat navigate.

Juga perbaiki duplikasi block build: di config lama ada dua property
build (yang kedua menimpa yang pertama jadi rolldownOptions hilang).
Sekarang rolldownOptions code-splitting vendor aktif.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:57:57 +07:00
Maulana SodiqinandClaude Opus 4.6 81999aa044 chore: trigger CI rebuild after npmDepsHash fix
Commit fix hash sebelumnya (d83a6d9) hanya menyentuh flake.nix sehingga
nx affected tidak mendeteksi app mana pun → matrix build kosong →
redesign belum ter-deploy. Formatting whitespace di libs/ui/index.css
membuat libs/ui terdeteksi affected dan cascade ke backoffice, gacha,
dimentorin, hackathon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:26:40 +07:00
Maulana SodiqinandClaude Opus 4.6 d83a6d9ec7 fix(nix): update npmDepsHash after adding shadcn deps
package-lock.json berubah karena +20 radix-ui primitives, lucide-react,
cmdk, input-otp. Nix CI gagal karena hash npm deps tidak match. Hash
baru diambil dari output CI yang gagal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:15:25 +07:00
Maulana SodiqinandClaude Opus 4.6 63dfb7299f feat(backoffice): redesign backoffice UI dengan shadcn/ui
Menyeluruh merancang ulang apps/backoffice memakai pola shadcn/ui sambil
mempertahankan seluruh palette warna existing (primary/neutral/success/
info/warning/danger) dan typography Bai Jamjuree.

UI library (libs/ui):
- Atoms existing (Button, Input, Label, Textarea, Card, Dialog, Drawer,
  Form, Select, Toggle) dirombak ke pola shadcn, API dipertahankan agar
  sibling apps (gacha, dimentorin, hackathon, qrcampaign) tidak break
- Select atom beralih dari native ke Radix Select + NativeSelect fallback
- 18 atom baru: Badge, Avatar, Separator, Skeleton, Tabs, Tooltip,
  DropdownMenu, Popover, AlertDialog, Checkbox, RadioGroup, Switch,
  ScrollArea, Sheet, Sidebar, Breadcrumb, Table, + use-mobile hook
- DataTable, Filter, BackofficeWrapper, Pagination, Modal (alias Dialog)
  direstyle dengan token baru
- CSS variable layer shadcn ditambahkan (--color-primary, --color-sidebar-*,
  dll) dipetakan ke palette existing; tw-animate-css di-import untuk
  transisi

Backoffice shell:
- Sidebar bersarang SidebarProvider + SidebarInset, built-in mobile Sheet,
  user DropdownMenu (Avatar + logout), ikon lucide-react

Pages redesign (19 list/dashboard/settings):
- accounts, cms-events, cms-testimonials, dashboard, dashboard-dimentorin
  (dengan recharts di Card), feedback-review-dimentorin, gacha-roll,
  hackathon-dashboard, hackathon-submissions, hackathon-teams,
  hackathon-users, permissions, prizes, roadmap-dimentorin, roles,
  session-dimentorin, settings-dimentorin (Tabs), transactions,
  users-dimentorin (Tabs mentor/mentee)
- Pola unified: BackofficeWrapper > Card (CardHeader toolbar + CardContent
  DataTable) + AlertDialog untuk delete confirm
- Form accounts_/$id diredesign; form pages lain tetap pakai
  ControlledInputField yang sekarang berbasis shadcn Input

Dependencies:
- +20 radix-ui primitives, lucide-react, cmdk, input-otp
- Select native patched di dimentorin (profile-sidebar, schedule step)
  untuk tetap kompatibel via NativeSelect alias

Verifikasi:
- nx build backoffice: sukses (2.33s, 4801 modules)
- nx build dimentorin/gacha/hackathon/qrcampaign: semua sukses (zero
  regresi dari rewrite libs/ui)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:01:47 +07:00
maulanasdqn 53079fe868 refactor: move backoffice sidebar from shared libs to app-local component 2026-04-12 00:11:04 +07:00
maulanasdqnandClaude Opus 4.6 2944a2b98a chore: remove dead code and unused dependencies
Deleted:
- libs/utils/src/react-router/ — old file-based routing system (replaced by TanStack Router)
- next-themes dependency (unused since Astro migration)
- react-router-dom from vite external config

Cleaned:
- Removed react-router barrel export from utils/index.ts
- Kept landing data files (still used by Astro components)
- Kept QueryProvider and ModalLoginProvider (still used by apps)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:37:28 +07:00
maulanasdqn 79f087e2fc fix: replace beforeLoad redirect with client-side navigate on backoffice index route 2026-04-11 23:24:59 +07:00
138 changed files with 11660 additions and 4811 deletions

No files matched your search

+8 -1
View File
@@ -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
View File
@@ -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>
);
}
+233
View File
@@ -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>
);
};
+40
View File
@@ -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 {
-3
View File
@@ -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>
) )
+16 -51
View File
@@ -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> );
)
} }
@@ -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,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,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}
</section> columns={columns}
</main> table={table}
</Fragment> manualPagination
) pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</CardContent>
</Card>
</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>
)}
</For>
</div> </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> </div>
</CardContent>
</Card>
);
}
<div> function DashboardDimentorinPage() {
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200"> const { data: mentorData } = useMentorList({
Trends & Analytics per_page: 5,
</Button> sort_by: 'rating',
order: 'desc',
});
const { data: userData } = useUserList({ per_page: 1 });
const { data: sessionsData } = useMySessions();
<div className="grid grid-cols-7 gap-x-5"> const totalMentors = mentorData?.meta?.total ?? 0;
<div className="bg-white px-6 py-4 rounded-lg col-span-5"> const totalUsers = userData?.meta?.total ?? 0;
<div className="flex items-center justify-between mb-7"> const totalSessions = sessionsData?.total ?? 0;
<h2 className="font-semibold text-p3 text-neutral-700">User Growth</h2> const topMentors = mentorData?.data ?? [];
<div></div> const activeMentors = topMentors.filter((m) => m.status === 'active').length;
</div> const completedSessions =
<UserGrowthChart /> sessionsData?.sessions?.filter((s) => s.status === 'completed').length ?? 0;
</div>
<div className="bg-white px-6 py-4 rounded-lg col-span-2">
<h2 className="font-semibold text-p3 text-neutral-700 mb-7">Session Status</h2>
<SessionStatusChart />
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-x-5"> 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> = {};
<div>
<table className="w-full">
<thead>
<tr className="text-label1 bg-primary-50 text-left rounded-full">
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
<th className="font-medium py-4 px-5 rounded-r-lg">Avg Rating</th>
</tr>
</thead>
<tbody>
{topMentors.slice(0, 5).map((mentor, index) => (
<tr key={mentor.id} className="shadow rounded-lg">
<td className="py-4 px-5">{index + 1}</td>
<td className="py-4 px-5">{mentor.fullname ?? '-'}</td>
<td className="py-4 px-5">{mentor.rating?.toFixed(1) ?? '-'}</td>
</tr>
))}
{topMentors.length === 0 && (
<tr>
<td colSpan={3} className="py-4 px-5 text-center text-neutral-400">Belum ada data</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<div className="bg-white px-6 py-4 rounded-lg">
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top Booked Mentoring Topics</h2>
<div>
<table className="w-full">
<thead>
<tr className="text-label1 bg-primary-50 text-left font-medium">
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
<th className="font-medium py-4 px-5 w-3/5">Topik</th>
<th className="font-medium py-4 px-5 rounded-r-lg">Total Sesi</th>
</tr>
</thead>
<tbody>
{(() => {
const sessions = sessionsData?.sessions ?? []
const topicCount: Record<string, number> = {}
sessions.forEach((s) => { sessions.forEach((s) => {
topicCount[s.topic] = (topicCount[s.topic] ?? 0) + 1 topicCount[s.topic] = (topicCount[s.topic] ?? 0) + 1;
}) });
const topTopics = Object.entries(topicCount) return Object.entries(topicCount)
.sort(([, a], [, b]) => b - a) .sort(([, a], [, b]) => b - a)
.slice(0, 5) .slice(0, 5);
if (topTopics.length === 0) { }, [sessionsData]);
return ( return (
<tr> <BackofficeWrapper
<td colSpan={3} className="py-4 px-5 text-center text-neutral-400">Belum ada data</td> title="Dimentorin Overview"
</tr> description="Ringkasan metrik platform mentoring"
) >
} <section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
return topTopics.map(([topic, count], index) => ( <StatCard icon={Users} label="Total Users" value={totalUsers} />
<tr key={topic} className="shadow rounded-lg"> <StatCard icon={UserCog} label="Total Mentors" value={totalMentors} />
<td className="py-4 px-5">{index + 1}</td> <StatCard
<td className="py-4 px-5">{topic}</td> icon={CalendarClock}
<td className="py-4 px-5">{count}</td> label="Total Sessions"
</tr> value={totalSessions}
/>
<StatCard
icon={Activity}
label="Active Mentors"
value={activeMentors}
/>
<StatCard
icon={CircleCheck}
label="Completed Sessions"
value={completedSessions}
/>
</section>
<section className="grid grid-cols-1 gap-4 lg:grid-cols-7">
<Card className="lg:col-span-5">
<CardHeader>
<CardTitle>User Growth</CardTitle>
<CardDescription>Pertumbuhan user dari waktu ke waktu</CardDescription>
</CardHeader>
<CardContent>
<UserGrowthChart />
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Session Status</CardTitle>
<CardDescription>Distribusi status sesi</CardDescription>
</CardHeader>
<CardContent>
<SessionStatusChart />
</CardContent>
</Card>
</section>
<section className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Top 5 Mentors</CardTitle>
<CardDescription>Mentor dengan rating tertinggi</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border border-neutral-200">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[10%]">No.</TableHead>
<TableHead>Nama Lengkap</TableHead>
<TableHead>Avg Rating</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topMentors.length === 0 ? (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={3}
className="py-6 text-center text-sm text-muted-foreground"
>
Belum ada data
</TableCell>
</TableRow>
) : (
topMentors.slice(0, 5).map((mentor, index) => (
<TableRow key={mentor.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>{mentor.fullname ?? '-'}</TableCell>
<TableCell>
{mentor.rating?.toFixed(1) ?? '-'}
</TableCell>
</TableRow>
)) ))
})()} )}
</tbody> </TableBody>
</table> </Table>
</div>
</div>
</div> </div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Top Booked Topics</CardTitle>
<CardDescription>Topik mentoring paling banyak dibooking</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border border-neutral-200">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[10%]">No.</TableHead>
<TableHead>Topik</TableHead>
<TableHead>Total Sesi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topTopics.length === 0 ? (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={3}
className="py-6 text-center text-sm text-muted-foreground"
>
Belum ada data
</TableCell>
</TableRow>
) : (
topTopics.map(([topic, count], index) => (
<TableRow key={topic}>
<TableCell>{index + 1}</TableCell>
<TableCell>{topic}</TableCell>
<TableCell>{count}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div> </div>
</CardContent>
</Card>
</section>
</BackofficeWrapper> </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 >
</Button> <Pencil />
<Button <span>Edit</span>
variant="text" </DropdownMenuItem>
size="sm" <DropdownMenuItem
className="text-[10px] text-neutral-500 p-0 font-normal hover:bg-transparent" variant="destructive"
onClick={() => setDeleteId(null)} onSelect={() => setDeleteId(item.id)}
> >
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" </ul>
onClick={() => setDeleteId(item.id)} )}
> </CardContent>
Delete </Card>
</Button>
)} <DeleteConfirmDialog
</div> open={!!deleteId}
</div> onOpenChange={(o) => !o && setDeleteId(null)}
onConfirm={() => deleteId && handleDelete(deleteId)}
<img src="gacha-clip.webp" alt={item.name} /> title="Hapus item gacha ini?"
</div> />
))} </BackofficeWrapper>
</div> );
</section>
</div>
<img
src="gacha.webp"
alt=""
className="rounded-lg hidden xl:block xl:min-w-[436px] h-auto object-cover"
/>
</div>
</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}
</section> columns={columns}
</BackofficeWrapper> table={table}
) manualPagination
pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</TabsContent>
<TabsContent value="platform" className="mt-4">
<div className="py-10 text-center text-sm text-muted-foreground">
Platform feedback belum tersedia
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</BackofficeWrapper>
);
} }
@@ -1,14 +1,17 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { 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}
</section> columns={columns}
</main> table={table}
</Fragment> manualPagination
) pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</CardContent>
</Card>
<DeleteConfirmDialog
open={!!deleteId}
onOpenChange={(o) => !o && setDeleteId(null)}
onConfirm={() => deleteId && handleDelete(deleteId)}
title="Hapus item gacha ini?"
/>
</BackofficeWrapper>
);
} }
@@ -1,61 +1,82 @@
import { createFileRoute } from '@tanstack/react-router' import { 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}
</section> columns={columns}
</BackofficeWrapper> table={table}
) manualPagination
pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</CardContent>
</Card>
</BackofficeWrapper>
);
} }
@@ -1,6 +1,6 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { 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}
</section> columns={mentorColumns}
</BackofficeWrapper> table={mentorTable}
) manualPagination
pageCount={Math.ceil(mentorTotal / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</TabsContent>
<TabsContent value="mentee" className="mt-4">
{menteeLoading ? (
<div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : (
<DataTable
data={mentees}
columns={menteeColumns}
table={menteeTable}
manualPagination
pageCount={Math.ceil(menteeTotal / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
<DeleteConfirmDialog
open={!!deletingId}
onOpenChange={(o) => !o && setDeletingId(null)}
onConfirm={() => deletingId && handleDelete(deletingId)}
title="Hapus akun mentor ini?"
/>
</BackofficeWrapper>
);
} }
+24 -6
View File
@@ -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' })
}
} 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>
)
} }
throw redirect({ to: '/auth/login' })
},
})
+6 -1
View File
@@ -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,
+2
View File
@@ -1 +1,3 @@
VITE_API_URL= VITE_API_URL=
VITE_DISABLE_AUTH=false
VITE_GITHUB_CLIENT_ID=
+141
View File
@@ -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}`);
});
}
});
+215
View File
@@ -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);
}
});
}
});
+46
View File
@@ -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

+20
View File
@@ -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);
}
} }
+123 -20
View File
@@ -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,18 +604,41 @@ 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(
AuthenticatedRouteChildren, AuthenticatedRouteChildren,
) )
@@ -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>
) )
} }
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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