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 12174 additions and 5325 deletions

No files matched your search

+9 -2
View File
@@ -4,7 +4,7 @@
dist
tmp
out-tsc
docs/FigmaImage/
# dependencies
node_modules
@@ -72,4 +72,11 @@ out
result
.claude/worktrees
.claude/settings.local.json
.claude/settings.local.json
# Screenshots and Test Reports
**/screenshots
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache
+1
View File
@@ -1 +1,2 @@
VITE_API_URL=
VITE_GITHUB_CLIENT_ID=
@@ -0,0 +1,79 @@
import * as React from 'react';
import type { Row, Table } from '@tanstack/react-table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Checkbox,
} from '@imphnen-frontend-service/ui/atoms';
/** Renders a header checkbox that toggles all rows (with indeterminate support). */
export function SelectAllCheckbox<T>({ table }: { table: Table<T> }) {
return (
<Checkbox
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? 'indeterminate'
: false
}
onCheckedChange={(v) =>
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
}
aria-label="Select all"
/>
);
}
/** Per-row checkbox. */
export function RowSelectCheckbox<T>({ row }: { row: Row<T> }) {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="Select row"
/>
);
}
type DeleteConfirmProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
title?: string;
description?: string;
};
export function DeleteConfirmDialog({
open,
onOpenChange,
onConfirm,
title = 'Hapus data ini?',
description = 'Tindakan ini tidak dapat dibatalkan. Data akan dihapus permanen.',
}: DeleteConfirmProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Batal</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Hapus
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+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 'tailwindcss';
@import 'tw-animate-css';
@source "../../../libs/ui/**/*.{ts,tsx}";
@theme {
@@ -96,6 +97,45 @@
/* ~10px */
--text-label3: 0.677rem;
/* ~8px */
/* shadcn/ui semantic tokens — aliased to existing palette (keep colors consistent) */
--color-background: #ffffff;
--color-foreground: #3d3d3d;
--color-card: #ffffff;
--color-card-foreground: #3d3d3d;
--color-popover: #ffffff;
--color-popover-foreground: #3d3d3d;
--color-primary: #23a1eb;
--color-primary-foreground: #ffffff;
--color-secondary: #e7e7e7;
--color-secondary-foreground: #3d3d3d;
--color-muted: #f6f6f6;
--color-muted-foreground: #6d6d6d;
--color-accent: #e1f0fd;
--color-accent-foreground: #085f9c;
--color-destructive: #ff5242;
--color-destructive-foreground: #ffffff;
--color-border: #d1d1d1;
--color-input: #d1d1d1;
--color-ring: #3eb0f2;
--color-sidebar: #ffffff;
--color-sidebar-foreground: #4f4f4f;
--color-sidebar-primary: #23a1eb;
--color-sidebar-primary-foreground: #ffffff;
--color-sidebar-accent: #f0f8ff;
--color-sidebar-accent-foreground: #085f9c;
--color-sidebar-border: #e7e7e7;
--color-sidebar-ring: #3eb0f2;
--color-chart-1: #23a1eb;
--color-chart-2: #35ba43;
--color-chart-3: #04acf3;
--color-chart-4: #ffed27;
--color-chart-5: #ff5242;
--radius: 8px;
--font-sans: 'Bai Jamjuree', sans-serif;
}
@layer base {
+4 -7
View File
@@ -1,4 +1,3 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { QueryProvider } from '@imphnen-frontend-service/utils'
@@ -19,10 +18,8 @@ const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Failed to find the root element')
createRoot(rootElement).render(
<StrictMode>
<QueryProvider>
<RouterProvider router={router} />
<Toaster position="top-right" />
</QueryProvider>
</StrictMode>
<QueryProvider>
<RouterProvider router={router} />
<Toaster position="top-right" />
</QueryProvider>
)
+17 -52
View File
@@ -1,63 +1,28 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { SessionToken } from '@imphnen-frontend-service/service'
import { useState } from 'react'
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms'
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
import { SessionToken } from '@imphnen-frontend-service/service';
import {
SidebarInset,
SidebarProvider,
} from '@imphnen-frontend-service/ui/atoms';
import { BackofficeSidebar } from '../components/sidebar';
export const Route = createFileRoute('/_authenticated')({
beforeLoad: () => {
const session = SessionToken.get()
const session = SessionToken.get();
if (!session?.token?.access_token) {
throw redirect({ to: '/auth/login' })
throw redirect({ to: '/auth/login' });
}
},
component: AuthenticatedLayout,
})
});
function AuthenticatedLayout() {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
return (
<div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar
isOpen={mobileSidebarOpen}
onClose={() => setMobileSidebarOpen(false)}
/>
<div className="flex-1 overflow-auto">
<header
className={
'lg:hidden sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' +
(mobileSidebarOpen ? 'z-0' : 'z-30')
}
>
<button
type="button"
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700 cursor-pointer"
onClick={() => setMobileSidebarOpen(true)}
aria-label="Open sidebar"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<h1 className="text-p3 font-semibold text-primary-700">
IMPHNEN Backoffice
</h1>
</header>
<Outlet />
</div>
</div>
</div>
)
<SidebarProvider>
<BackofficeSidebar />
<SidebarInset>
<Outlet />
</SidebarInset>
</SidebarProvider>
);
}
@@ -1,5 +1,4 @@
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"
import { PieLabelProps } from "recharts/types/polar/Pie"
type ChartProps = {
name: string
@@ -14,14 +13,20 @@ const chartData: ChartProps[] = [
]
const RADIAN = Math.PI / 180;
const renderCustomizedLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent }: PieLabelProps) => {
const renderCustomizedLabel = (props: any) => {
const cx = props.cx ?? 0
const cy = props.cy ?? 0
const midAngle = props.midAngle ?? 0
const innerRadius = props.innerRadius ?? 0
const outerRadius = props.outerRadius ?? 0
const percent = props.percent ?? 0
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-(midAngle ?? 0) * RADIAN);
const y = cy + radius * Math.sin(-(midAngle ?? 0) * RADIAN);
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
return (
<text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central">
{`${((percent ?? 1) * 100).toFixed(0)}%`}
{`${(percent * 100).toFixed(0)}%`}
</text>
);
};
@@ -1,4 +1,4 @@
import { Button, Input, Select, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
import { Button, Input, NativeSelect as Select, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils"
import { FC } from "react"
@@ -1,4 +1,4 @@
import { Button, Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
import { Button, Input, NativeSelect as Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils"
import { FC } from "react"
@@ -1,13 +1,23 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import * as React from 'react'
import { Fragment, useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import { Filter as FilterIcon, Search, Pencil } from 'lucide-react';
import {
FilterOutlined,
SearchOutlined,
EditOutlined,
} from '@ant-design/icons'
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms'
Button,
Card,
CardContent,
CardHeader,
Checkbox,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Badge,
} from '@imphnen-frontend-service/ui/atoms';
import {
DataTable,
Filter,
BackofficeWrapper,
} from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
@@ -15,107 +25,96 @@ import {
PaginationState,
useReactTable,
RowSelectionState,
} from '@tanstack/react-table'
} from '@tanstack/react-table';
import {
useUserList,
TUsersListItem,
} from '@imphnen-frontend-service/service'
} from '@imphnen-frontend-service/service';
export const Route = createFileRoute('/_authenticated/accounts')({
component: AccountsPage,
})
});
function AccountsPage() {
const navigate = useNavigate()
const [search, setSearch] = useState('')
const navigate = useNavigate();
const [search, setSearch] = React.useState('');
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
})
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
const [showFilter, setShowFilter] = useState(false)
pageSize: 10,
});
const [rowSelection, setRowSelection] =
React.useState<RowSelectionState>({});
const [showFilter, setShowFilter] = React.useState(false);
const { data: usersData, isLoading } = useUserList({
search,
page: pagination.pageIndex + 1,
per_page: pagination.pageSize,
})
});
const users: TUsersListItem[] = usersData?.data ?? []
const totalItems = usersData?.meta?.total ?? users.length
const users: TUsersListItem[] = usersData?.data ?? [];
const totalItems = usersData?.meta?.total ?? users.length;
const columns: ColumnDef<TUsersListItem>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
<Checkbox
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? 'indeterminate'
: false
}
onCheckedChange={(v) =>
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
<Checkbox
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="Select row"
/>
),
},
{
header: 'No',
accessorKey: 'id',
},
{
header: 'Nama Lengkap',
accessorKey: 'fullname',
},
{
header: 'Email',
accessorKey: 'email',
},
{
header: 'Role',
accessorKey: 'role',
},
{ header: 'No', accessorKey: 'id' },
{ header: 'Nama Lengkap', accessorKey: 'fullname' },
{ header: 'Email', accessorKey: 'email' },
{ header: 'Role', accessorKey: 'role' },
{
header: 'Status',
accessorKey: 'is_active',
cell: ({ row }) => (
<span className={row.original.is_active ? 'text-success-500' : 'text-danger-500'}>
<Badge variant={row.original.is_active ? 'success' : 'destructive'}>
{row.original.is_active ? 'Aktif' : 'Tidak Aktif'}
</span>
</Badge>
),
},
{
header: 'Action',
cell: ({ row }) => (
<Button
variant="primary"
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation()
navigate({ to: '/accounts/$id', params: { id: row.original.id } })
e.stopPropagation();
navigate({ to: '/accounts/$id', params: { id: row.original.id } });
}}
className="flex items-center gap-2"
>
<EditOutlined /> Edit
<Pencil className="size-3.5" /> Edit
</Button>
),
},
]
];
const table = useReactTable({
data: users,
columns,
state: {
pagination,
rowSelection,
},
state: { pagination, rowSelection },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
@@ -123,52 +122,65 @@ function AccountsPage() {
onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true,
})
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Data Akun</h1>
</header>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<BackofficeWrapper
title="Data Akun"
description="Kelola akun pengguna yang terdaftar"
>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Cari berdasarkan nama lengkap, email"
className="pl-12 w-full max-h-full"
placeholder="Cari nama lengkap atau email…"
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex items-center gap-3"
disabled
onClick={() => setShowFilter(!showFilter)}
>
<FilterOutlined />
Filters
</Button>
{showFilter && (
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
<Filter onClose={() => setShowFilter(false)} options={[]} />
</div>
)}
</div>
<Popover open={showFilter} onOpenChange={setShowFilter}>
<PopoverTrigger asChild>
<Button variant="secondary" size="md">
<FilterIcon className="size-4" />
Filters
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-auto p-0">
<Filter
onClose={() => setShowFilter(false)}
options={[
{ id: 'all', value: 'all', label: 'Semua' },
{ id: 'active', value: 'active', label: 'Aktif' },
{ id: 'inactive', value: 'inactive', label: 'Tidak Aktif' },
]}
/>
</PopoverContent>
</Popover>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-neutral-400">Loading...</div>
<div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : (
<DataTable data={users} columns={columns} table={table} />
<DataTable
data={users}
columns={columns}
table={table}
manualPagination
pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</section>
</main>
</Fragment>
)
</CardContent>
</Card>
</BackofficeWrapper>
);
}
@@ -1,110 +1,122 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { InputField } from '@imphnen-frontend-service/ui/molecules'
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import { toast } from 'sonner';
import { ArrowLeft } from 'lucide-react';
import {
Button,
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@imphnen-frontend-service/ui/atoms';
import { InputField } from '@imphnen-frontend-service/ui/molecules';
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import {
useUserList,
useUpdateUserById,
} from '@imphnen-frontend-service/service'
} from '@imphnen-frontend-service/service';
export const Route = createFileRoute('/_authenticated/accounts_/$id')({
component: AccountsEditPage,
})
});
function AccountsEditPage() {
const { id } = Route.useParams()
const navigate = useNavigate()
const updateUser = useUpdateUserById()
const { id } = Route.useParams();
const navigate = useNavigate();
const updateUser = useUpdateUserById();
const { data: usersData, isLoading } = useUserList({ search: '', per_page: 100 })
const user = usersData?.data?.find((u) => u.id === id)
const { data: usersData, isLoading } = useUserList({
search: '',
per_page: 100,
});
const user = usersData?.data?.find((u) => u.id === id);
const [fullName, setFullName] = useState('')
const [email, setEmail] = useState('')
const [fullName, setFullName] = React.useState('');
const [email, setEmail] = React.useState('');
useEffect(() => {
React.useEffect(() => {
if (user) {
setFullName(user.fullname)
setEmail(user.email)
setFullName(user.fullname);
setEmail(user.email);
}
}, [user])
}, [user]);
const handleSubmit = async () => {
try {
await updateUser.mutateAsync({ id, data: { fullname: fullName, email } })
toast.success('Data akun berhasil diperbarui')
navigate({ to: '/accounts' })
await updateUser.mutateAsync({
id,
data: { fullname: fullName, email },
});
toast.success('Data akun berhasil diperbarui');
navigate({ to: '/accounts' });
} catch (error) {
console.log(error)
toast.error('Data akun gagal diperbarui')
console.log(error);
toast.error('Data akun gagal diperbarui');
}
}
if (isLoading) {
return (
<main className="w-full px-[48px] py-[40px]">
<div className="text-center py-8 text-neutral-400">Loading...</div>
</main>
)
}
};
return (
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<div className="max-w-2xl mx-auto w-full">
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => navigate({ to: '/accounts' })}
className="text-primary-500 hover:text-primary-600"
>
<ArrowLeftOutlined className="text-[20px]" />
</button>
<h1 className="text-2xl font-bold text-gray-900">Edit Data Akun</h1>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex flex-col gap-6">
<InputField
label="Nama Lengkap"
type="text"
placeholder="Masukkan Nama Lengkap"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
size="lg"
className="w-full"
/>
<InputField
label="Email"
type="text"
placeholder="Masukkan Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
size="lg"
className="w-full"
/>
<div className="flex gap-3 pt-4">
<Button
variant="primary"
size="lg"
className="w-full"
onClick={handleSubmit}
>
Perbarui Data
</Button>
<Button
variant="bordered"
size="lg"
className="w-full"
onClick={() => navigate({ to: '/accounts' })}
>
Batal
</Button>
</div>
</div>
</div>
<BackofficeWrapper title="Edit Data Akun">
<div className="mx-auto w-full max-w-2xl">
<Button
variant="text"
size="sm"
onClick={() => navigate({ to: '/accounts' })}
className="mb-4 -ml-2"
>
<ArrowLeft className="size-4" />
Kembali
</Button>
<Card>
<CardHeader>
<CardTitle>Edit Data Akun</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : (
<div className="flex flex-col gap-4">
<InputField
label="Nama Lengkap"
type="text"
placeholder="Masukkan nama lengkap"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
size="md"
/>
<InputField
label="Email"
type="text"
placeholder="Masukkan email"
value={email}
onChange={(e) => setEmail(e.target.value)}
size="md"
/>
</div>
)}
</CardContent>
<CardFooter className="justify-end gap-2">
<Button
variant="secondary"
size="md"
onClick={() => navigate({ to: '/accounts' })}
>
Batal
</Button>
<Button
variant="primary"
size="md"
onClick={handleSubmit}
disabled={updateUser.isPending || isLoading}
>
{updateUser.isPending ? 'Menyimpan…' : 'Perbarui Data'}
</Button>
</CardFooter>
</Card>
</div>
</main>
)
</BackofficeWrapper>
);
}
@@ -1,13 +1,27 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { Fragment, useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
import {
SearchOutlined,
EditOutlined,
DeleteOutlined,
PlusOutlined,
} from '@ant-design/icons'
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Badge,
Button,
Card,
CardContent,
CardHeader,
Checkbox,
Input,
} from '@imphnen-frontend-service/ui/atoms';
import {
DataTable,
BackofficeWrapper,
} from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
@@ -15,76 +29,77 @@ import {
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table'
} from '@tanstack/react-table';
import {
useEventList,
useDeleteEvent,
TEventsListItem,
} from '@imphnen-frontend-service/service'
import React from 'react'
import { toast } from 'sonner'
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
export const Route = createFileRoute('/_authenticated/cms-events')({
component: CmsEventsPage,
})
});
function CmsEventsPage() {
const navigate = useNavigate()
const [search, setSearch] = useState('')
const [deleteId, setDeleteId] = useState<string | null>(null)
const navigate = useNavigate();
const [search, setSearch] = React.useState('');
const [deleteId, setDeleteId] = React.useState<string | null>(null);
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
})
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
pageSize: 10,
});
const [rowSelection, setRowSelection] =
React.useState<RowSelectionState>({});
const { data: eventsData, isLoading } = useEventList({
search,
page: pagination.pageIndex + 1,
per_page: pagination.pageSize,
})
const deleteEvent = useDeleteEvent()
});
const deleteEvent = useDeleteEvent();
const events: TEventsListItem[] = eventsData?.data ?? []
const totalItems = eventsData?.meta?.total ?? events.length
const events: TEventsListItem[] = eventsData?.data ?? [];
const totalItems = eventsData?.meta?.total ?? events.length;
const handleDelete = async (id: string) => {
try {
await deleteEvent.mutateAsync(id)
toast.success('Data event berhasil dihapus')
setDeleteId(null)
await deleteEvent.mutateAsync(id);
toast.success('Data event berhasil dihapus');
setDeleteId(null);
} catch (error) {
console.log(error)
toast.error('Data event gagal dihapus')
console.log(error);
toast.error('Data event gagal dihapus');
}
}
};
const columns: ColumnDef<TEventsListItem>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
<Checkbox
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? 'indeterminate'
: false
}
onCheckedChange={(v) =>
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
<Checkbox
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="Select row"
/>
),
},
{
header: 'Name',
accessorKey: 'name',
},
{ header: 'Name', accessorKey: 'name' },
{
header: 'Location',
accessorKey: 'location',
@@ -112,81 +127,49 @@ function CmsEventsPage() {
header: 'Online',
accessorKey: 'is_online',
cell: ({ row }) => (
<span
className={`px-2 py-1 rounded-full text-label2 font-medium ${
row.original.is_online
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-700'
}`}
>
<Badge variant={row.original.is_online ? 'success' : 'secondary'}>
{row.original.is_online ? 'Online' : 'Offline'}
</span>
</Badge>
),
},
{
header: 'Action',
cell: ({ row }) => (
<div className="flex gap-[8px]">
<div className="flex items-center gap-2">
<Button
variant="primary"
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation()
navigate({ to: '/cms-events/$id', params: { id: row.original.id } })
e.stopPropagation();
navigate({
to: '/cms-events/$id',
params: { id: row.original.id },
});
}}
className="flex items-center gap-2"
>
<EditOutlined /> Update
<Pencil className="size-3.5" />
Update
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
setDeleteId(row.original.id);
}}
>
<Trash2 className="size-3.5" />
Delete
</Button>
{deleteId === row.original.id ? (
<div className="flex items-center gap-2">
<span className="text-label2 text-neutral-500">Yakin?</span>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleDelete(row.original.id)
}}
>
Ya
</Button>
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation()
setDeleteId(null)
}}
>
Batal
</Button>
</div>
) : (
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation()
setDeleteId(row.original.id)
}}
className="flex items-center gap-2"
>
<DeleteOutlined /> Delete
</Button>
)}
</div>
),
},
]
];
const table = useReactTable({
data: events,
columns,
state: {
pagination,
rowSelection,
},
state: { pagination, rowSelection },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
@@ -194,53 +177,74 @@ function CmsEventsPage() {
onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true,
})
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">CMS Events</h1>
</header>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<BackofficeWrapper title="CMS Events" description="Kelola event komunitas">
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Cari berdasarkan nama event"
className="pl-12 w-full max-h-full"
placeholder="Cari nama event…"
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex gap-3 text-nowrap"
onClick={() => navigate({ to: '/cms-events/create' })}
>
<PlusOutlined />
Tambah Event
</Button>
</div>
<Button
onClick={() => navigate({ to: '/cms-events/create' })}
size="md"
>
<Plus className="size-4" />
Tambah Event
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-neutral-400">Loading...</div>
<div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : (
<DataTable
data={events}
columns={columns}
pageSize={9}
table={table}
manualPagination
pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</section>
</main>
</Fragment>
)
</CardContent>
</Card>
<AlertDialog
open={!!deleteId}
onOpenChange={(o) => !o && setDeleteId(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hapus event ini?</AlertDialogTitle>
<AlertDialogDescription>
Tindakan ini tidak dapat dibatalkan. Event akan dihapus permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Batal</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteId && handleDelete(deleteId)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Hapus
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</BackofficeWrapper>
);
}
@@ -1,13 +1,26 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { Fragment, useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
import {
SearchOutlined,
EditOutlined,
DeleteOutlined,
PlusOutlined,
} from '@ant-design/icons'
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Card,
CardContent,
CardHeader,
Checkbox,
Input,
} from '@imphnen-frontend-service/ui/atoms';
import {
DataTable,
BackofficeWrapper,
} from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
@@ -15,86 +28,84 @@ import {
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table'
} from '@tanstack/react-table';
import {
useTestimonialList,
useDeleteTestimonial,
TTestimonialsListItem,
} from '@imphnen-frontend-service/service'
import React from 'react'
import { toast } from 'sonner'
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
export const Route = createFileRoute('/_authenticated/cms-testimonials')({
component: CmsTestimonialsPage,
})
});
function CmsTestimonialsPage() {
const navigate = useNavigate()
const [search, setSearch] = useState('')
const [deleteId, setDeleteId] = useState<string | null>(null)
const navigate = useNavigate();
const [search, setSearch] = React.useState('');
const [deleteId, setDeleteId] = React.useState<string | null>(null);
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
})
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
pageSize: 10,
});
const [rowSelection, setRowSelection] =
React.useState<RowSelectionState>({});
const { data: testimonialsData, isLoading } = useTestimonialList({
search,
page: pagination.pageIndex + 1,
per_page: pagination.pageSize,
})
const deleteTestimonial = useDeleteTestimonial()
});
const deleteTestimonial = useDeleteTestimonial();
const testimonials: TTestimonialsListItem[] = testimonialsData?.data ?? []
const totalItems = testimonialsData?.meta?.total ?? testimonials.length
const testimonials: TTestimonialsListItem[] = testimonialsData?.data ?? [];
const totalItems = testimonialsData?.meta?.total ?? testimonials.length;
const handleDelete = async (id: string) => {
try {
await deleteTestimonial.mutateAsync(id)
toast.success('Data testimonial berhasil dihapus')
setDeleteId(null)
await deleteTestimonial.mutateAsync(id);
toast.success('Data testimonial berhasil dihapus');
setDeleteId(null);
} catch (error) {
console.log(error)
toast.error('Data testimonial gagal dihapus')
console.log(error);
toast.error('Data testimonial gagal dihapus');
}
}
};
const columns: ColumnDef<TTestimonialsListItem>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
<Checkbox
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? 'indeterminate'
: false
}
onCheckedChange={(v) =>
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
<Checkbox
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
/>
),
},
{
header: 'User',
accessorKey: 'user_fullname',
},
{
header: 'Role',
accessorKey: 'role',
},
{ header: 'User', accessorKey: 'user_fullname' },
{ header: 'Role', accessorKey: 'role' },
{
header: 'Content',
accessorKey: 'content',
cell: ({ row }) => {
const content = row.original.content
return content.length > 80 ? `${content.substring(0, 80)}...` : content
const content = row.original.content;
return content.length > 80
? `${content.substring(0, 80)}…`
: content;
},
},
{
@@ -110,67 +121,41 @@ function CmsTestimonialsPage() {
{
header: 'Action',
cell: ({ row }) => (
<div className="flex gap-[8px]">
<div className="flex items-center gap-2">
<Button
variant="primary"
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation()
navigate({ to: '/cms-testimonials/$id', params: { id: row.original.id } })
e.stopPropagation();
navigate({
to: '/cms-testimonials/$id',
params: { id: row.original.id },
});
}}
className="flex items-center gap-2"
>
<EditOutlined /> Update
<Pencil className="size-3.5" />
Update
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
setDeleteId(row.original.id);
}}
>
<Trash2 className="size-3.5" />
Delete
</Button>
{deleteId === row.original.id ? (
<div className="flex items-center gap-2">
<span className="text-label2 text-neutral-500">Yakin?</span>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleDelete(row.original.id)
}}
>
Ya
</Button>
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation()
setDeleteId(null)
}}
>
Batal
</Button>
</div>
) : (
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation()
setDeleteId(row.original.id)
}}
className="flex items-center gap-2"
>
<DeleteOutlined /> Delete
</Button>
)}
</div>
),
},
]
];
const table = useReactTable({
data: testimonials,
columns,
state: {
pagination,
rowSelection,
},
state: { pagination, rowSelection },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
@@ -178,53 +163,78 @@ function CmsTestimonialsPage() {
onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true,
})
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">CMS Testimonials</h1>
</header>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<BackofficeWrapper
title="CMS Testimonials"
description="Kelola testimonial pengguna"
>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Cari berdasarkan nama user"
className="pl-12 w-full max-h-full"
placeholder="Cari nama user…"
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex gap-3 text-nowrap"
onClick={() => navigate({ to: '/cms-testimonials/create' })}
>
<PlusOutlined />
Tambah Testimonial
</Button>
</div>
<Button
onClick={() => navigate({ to: '/cms-testimonials/create' })}
size="md"
>
<Plus className="size-4" />
Tambah Testimonial
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-neutral-400">Loading...</div>
<div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : (
<DataTable
data={testimonials}
columns={columns}
pageSize={9}
table={table}
manualPagination
pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</section>
</main>
</Fragment>
)
</CardContent>
</Card>
<AlertDialog
open={!!deleteId}
onOpenChange={(o) => !o && setDeleteId(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hapus testimonial ini?</AlertDialogTitle>
<AlertDialogDescription>
Tindakan ini tidak dapat dibatalkan. Testimonial akan dihapus
permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Batal</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteId && handleDelete(deleteId)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Hapus
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</BackofficeWrapper>
);
}
@@ -1,152 +1,212 @@
import { createFileRoute } from '@tanstack/react-router'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms'
import { For } from '@imphnen-frontend-service/utils'
import { ReactElement } from 'react'
import { UserGrowthChart } from './_components/dashboard-dimentorin/chart/user-growth'
import { SessionStatusChart } from './_components/dashboard-dimentorin/chart/session-status'
import { useMentorList, useUserList, useMySessions } from '@imphnen-frontend-service/service'
import { createFileRoute } from '@tanstack/react-router';
import * as React from 'react';
import { Users, UserCog, CalendarClock, Activity, CircleCheck } from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@imphnen-frontend-service/ui/atoms';
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import { UserGrowthChart } from './_components/dashboard-dimentorin/chart/user-growth';
import { SessionStatusChart } from './_components/dashboard-dimentorin/chart/session-status';
import {
useMentorList,
useUserList,
useMySessions,
} from '@imphnen-frontend-service/service';
export const Route = createFileRoute('/_authenticated/dashboard-dimentorin')({
component: DashboardDimentorinPage,
})
});
function DashboardDimentorinPage(): ReactElement {
const { data: mentorData } = useMentorList({ per_page: 5, sort_by: 'rating', order: 'desc' })
const { data: userData } = useUserList({ per_page: 1 })
const { data: sessionsData } = useMySessions()
type StatCardProps = {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: React.ReactNode;
};
const totalMentors = mentorData?.meta?.total ?? 0
const totalUsers = userData?.meta?.total ?? 0
const totalSessions = sessionsData?.total ?? 0
const topMentors = mentorData?.data ?? []
function StatCard({ icon: Icon, label, value }: StatCardProps) {
return (
<Card>
<CardContent className="flex items-center gap-4 pt-6">
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary-100 text-primary-600">
<Icon className="size-5" />
</div>
<div className="flex flex-col">
<span className="text-2xl font-semibold leading-tight text-foreground">
{value}
</span>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
</CardContent>
</Card>
);
}
const overviewStats = [
{ label: 'Total Users', value: totalUsers },
{ label: 'Total Mentors', value: totalMentors },
{ label: 'Total Sessions', value: totalSessions },
{ label: 'Active Mentors', value: topMentors.filter((m) => m.status === 'active').length },
{ label: 'Completed Sessions', value: sessionsData?.sessions?.filter((s) => s.status === 'completed').length ?? 0 },
]
function DashboardDimentorinPage() {
const { data: mentorData } = useMentorList({
per_page: 5,
sort_by: 'rating',
order: 'desc',
});
const { data: userData } = useUserList({ per_page: 1 });
const { data: sessionsData } = useMySessions();
const totalMentors = mentorData?.meta?.total ?? 0;
const totalUsers = userData?.meta?.total ?? 0;
const totalSessions = sessionsData?.total ?? 0;
const topMentors = mentorData?.data ?? [];
const activeMentors = topMentors.filter((m) => m.status === 'active').length;
const completedSessions =
sessionsData?.sessions?.filter((s) => s.status === 'completed').length ?? 0;
const topTopics = React.useMemo(() => {
const sessions = sessionsData?.sessions ?? [];
const topicCount: Record<string, number> = {};
sessions.forEach((s) => {
topicCount[s.topic] = (topicCount[s.topic] ?? 0) + 1;
});
return Object.entries(topicCount)
.sort(([, a], [, b]) => b - a)
.slice(0, 5);
}, [sessionsData]);
return (
<BackofficeWrapper title="Dimentorin.dev">
<h1 className="text-p1 font-semibold text-neutral-700 mb-5">Overview</h1>
<BackofficeWrapper
title="Dimentorin Overview"
description="Ringkasan metrik platform mentoring"
>
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<StatCard icon={Users} label="Total Users" value={totalUsers} />
<StatCard icon={UserCog} label="Total Mentors" value={totalMentors} />
<StatCard
icon={CalendarClock}
label="Total Sessions"
value={totalSessions}
/>
<StatCard
icon={Activity}
label="Active Mentors"
value={activeMentors}
/>
<StatCard
icon={CircleCheck}
label="Completed Sessions"
value={completedSessions}
/>
</section>
<div className="space-y-14">
<div>
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
Overview
</Button>
<section className="grid grid-cols-1 gap-4 lg:grid-cols-7">
<Card className="lg:col-span-5">
<CardHeader>
<CardTitle>User Growth</CardTitle>
<CardDescription>Pertumbuhan user dari waktu ke waktu</CardDescription>
</CardHeader>
<CardContent>
<UserGrowthChart />
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Session Status</CardTitle>
<CardDescription>Distribusi status sesi</CardDescription>
</CardHeader>
<CardContent>
<SessionStatusChart />
</CardContent>
</Card>
</section>
<div className="grid grid-cols-5 gap-5">
<For data={overviewStats}>
{(stat, index) => (
<div key={index} className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">{stat.value}</h3>
<p className="text-neutral-400 text-p3">{stat.label}</p>
</div>
)}
</For>
</div>
</div>
<div>
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
Trends & Analytics
</Button>
<div className="grid grid-cols-7 gap-x-5">
<div className="bg-white px-6 py-4 rounded-lg col-span-5">
<div className="flex items-center justify-between mb-7">
<h2 className="font-semibold text-p3 text-neutral-700">User Growth</h2>
<div></div>
</div>
<UserGrowthChart />
</div>
<div className="bg-white px-6 py-4 rounded-lg col-span-2">
<h2 className="font-semibold text-p3 text-neutral-700 mb-7">Session Status</h2>
<SessionStatusChart />
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-x-5">
<div className="bg-white px-7 py-4 rounded-lg">
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top 5 Mentors</h2>
<div>
<table className="w-full">
<thead>
<tr className="text-label1 bg-primary-50 text-left rounded-full">
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
<th className="font-medium py-4 px-5 rounded-r-lg">Avg Rating</th>
</tr>
</thead>
<tbody>
{topMentors.slice(0, 5).map((mentor, index) => (
<tr key={mentor.id} className="shadow rounded-lg">
<td className="py-4 px-5">{index + 1}</td>
<td className="py-4 px-5">{mentor.fullname ?? '-'}</td>
<td className="py-4 px-5">{mentor.rating?.toFixed(1) ?? '-'}</td>
</tr>
))}
{topMentors.length === 0 && (
<tr>
<td colSpan={3} className="py-4 px-5 text-center text-neutral-400">Belum ada data</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<div className="bg-white px-6 py-4 rounded-lg">
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top Booked Mentoring Topics</h2>
<div>
<table className="w-full">
<thead>
<tr className="text-label1 bg-primary-50 text-left font-medium">
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
<th className="font-medium py-4 px-5 w-3/5">Topik</th>
<th className="font-medium py-4 px-5 rounded-r-lg">Total Sesi</th>
</tr>
</thead>
<tbody>
{(() => {
const sessions = sessionsData?.sessions ?? []
const topicCount: Record<string, number> = {}
sessions.forEach((s) => {
topicCount[s.topic] = (topicCount[s.topic] ?? 0) + 1
})
const topTopics = Object.entries(topicCount)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
if (topTopics.length === 0) {
return (
<tr>
<td colSpan={3} className="py-4 px-5 text-center text-neutral-400">Belum ada data</td>
</tr>
)
}
return topTopics.map(([topic, count], index) => (
<tr key={topic} className="shadow rounded-lg">
<td className="py-4 px-5">{index + 1}</td>
<td className="py-4 px-5">{topic}</td>
<td className="py-4 px-5">{count}</td>
</tr>
<section className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Top 5 Mentors</CardTitle>
<CardDescription>Mentor dengan rating tertinggi</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border border-neutral-200">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[10%]">No.</TableHead>
<TableHead>Nama Lengkap</TableHead>
<TableHead>Avg Rating</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topMentors.length === 0 ? (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={3}
className="py-6 text-center text-sm text-muted-foreground"
>
Belum ada data
</TableCell>
</TableRow>
) : (
topMentors.slice(0, 5).map((mentor, index) => (
<TableRow key={mentor.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>{mentor.fullname ?? '-'}</TableCell>
<TableCell>
{mentor.rating?.toFixed(1) ?? '-'}
</TableCell>
</TableRow>
))
})()}
</tbody>
</table>
)}
</TableBody>
</Table>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Top Booked Topics</CardTitle>
<CardDescription>Topik mentoring paling banyak dibooking</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border border-neutral-200">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[10%]">No.</TableHead>
<TableHead>Topik</TableHead>
<TableHead>Total Sesi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topTopics.length === 0 ? (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={3}
className="py-6 text-center text-sm text-muted-foreground"
>
Belum ada data
</TableCell>
</TableRow>
) : (
topTopics.map(([topic, count], index) => (
<TableRow key={topic}>
<TableCell>{index + 1}</TableCell>
<TableCell>{topic}</TableCell>
<TableCell>{count}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</section>
</BackofficeWrapper>
)
);
}
@@ -1,195 +1,185 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import {
PlusOutlined,
ReloadOutlined,
UsergroupAddOutlined,
UsergroupDeleteOutlined,
UserSwitchOutlined,
DeleteOutlined,
} from '@ant-design/icons'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { Fragment, useState } from 'react'
Plus,
UsersRound,
UserMinus,
UserCog,
RefreshCcw,
Pencil,
Trash2,
MoreHorizontal,
} from 'lucide-react';
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@imphnen-frontend-service/ui/atoms';
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import {
useUserList,
useGachaItemList,
useDeleteGachaItem,
TGachaItemDto,
} from '@imphnen-frontend-service/service'
import { toast } from 'sonner'
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
import { DeleteConfirmDialog } from '../../components/list-helpers';
export const Route = createFileRoute('/_authenticated/dashboard')({
component: DashboardPage,
})
});
type StatCardProps = {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: React.ReactNode;
};
function StatCard({ icon: Icon, label, value }: StatCardProps) {
return (
<Card>
<CardContent className="flex items-center gap-4 pt-6">
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary-100 text-primary-600">
<Icon className="size-5" />
</div>
<div className="flex flex-col">
<span className="text-2xl font-semibold leading-tight text-foreground">
{value}
</span>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
</CardContent>
</Card>
);
}
function DashboardPage() {
const navigate = useNavigate()
const [deleteId, setDeleteId] = useState<string | null>(null)
const navigate = useNavigate();
const [deleteId, setDeleteId] = React.useState<string | null>(null);
const { data: usersData } = useUserList({ per_page: 1 })
const { data: gachaItemsData } = useGachaItemList({ per_page: 9 })
const deleteItem = useDeleteGachaItem()
const { data: usersData } = useUserList({ per_page: 1 });
const { data: gachaItemsData } = useGachaItemList({ per_page: 9 });
const deleteItem = useDeleteGachaItem();
const totalUsers = usersData?.meta?.total ?? 0
const gachaItems: TGachaItemDto[] = gachaItemsData?.data ?? []
const totalUsers = usersData?.meta?.total ?? 0;
const gachaItems: TGachaItemDto[] = gachaItemsData?.data ?? [];
const handleDelete = async (id: string) => {
try {
await deleteItem.mutateAsync(id)
toast.success('Item berhasil dihapus')
setDeleteId(null)
await deleteItem.mutateAsync(id);
toast.success('Item berhasil dihapus');
setDeleteId(null);
} catch (error) {
console.log(error)
toast.error('Item gagal dihapus')
console.log(error);
toast.error('Item gagal dihapus');
}
}
};
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Dashboard</h1>
</header>
<BackofficeWrapper
title="Gacha Dashboard"
description="Ringkasan statistik & daftar item gacha"
>
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={UsersRound}
label="Participants"
value={totalUsers.toLocaleString('id-ID')}
/>
<StatCard
icon={RefreshCcw}
label="Gacha Items"
value={(gachaItemsData?.meta?.total ?? 0).toLocaleString('id-ID')}
/>
<StatCard icon={UserCog} label="Redeem" value="—" />
<StatCard icon={UserMinus} label="Inactive Users" value="—" />
</section>
<div className="flex justify-between gap-[40px] p-8 bg-white rounded-md">
<div className="w-full flex flex-col gap-[40px]">
<section>
<h2 className="text-p2 font-medium text-primary-500 mb-8">
Summary
</h2>
<div className="grid grid-cols-2 gap-4">
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
<UsergroupAddOutlined className="text-[20px]" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">{totalUsers}</h3>
<p className="text-label1 text-neutral-500">Participants</p>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
<ReloadOutlined className="text-[20px]" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">{gachaItemsData?.meta?.total ?? 0}</h3>
<p className="text-label1 text-neutral-500">
Gacha Items
</p>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
<UserSwitchOutlined className="text-[20px]" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">-</h3>
<p className="text-label1 text-neutral-500">Redeem</p>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
<UsergroupDeleteOutlined className="text-[20px]" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">-</h3>
<p className="text-label1 text-neutral-500">
Inactive Users
</p>
</div>
</div>
</div>
</section>
<section className="flex flex-col gap-8">
<div className="flex justify-between items-center">
<h2 className="text-p2 font-medium text-primary-500">
Gacha Items
</h2>
<Button
variant="primary"
size="sm"
className="items-end gap-3"
onClick={() => navigate({ to: '/dashboard/create' })}
>
<span>Tambah Item</span>
<PlusOutlined className="text-[16px]" />
</Button>
</div>
<div className="flex flex-col gap-4 max-h-140 overflow-auto">
{gachaItems.map((item) => (
<div
key={item.id}
className="bg-white overflow-clip rounded-lg shadow-sm flex justify-between border border-neutral-100"
>
<div className="flex flex-col py-4 px-6 gap-[8px]">
<div>
<h3 className="text-p3 text-primary-500 font-medium">
{item.name}
</h3>
<div className="flex items-center justify-start gap-10 text-label2 text-gray-500 mt-1">
<span>{item.id}</span>
</div>
</div>
<div className="flex justify-start gap-2">
<Button
variant="text"
size="sm"
className="text-[10px] text-neutral-500 p-0 font-normal hover:bg-transparent hover:text-primary-500"
onClick={() => navigate({ to: '/dashboard/$id', params: { id: item.id } })}
>
Edit
</Button>
{deleteId === item.id ? (
<div className="flex items-center gap-1">
<span className="text-[10px] text-neutral-400">Yakin?</span>
<Button
variant="text"
size="sm"
className="text-[10px] text-red-500 p-0 font-normal hover:bg-transparent hover:text-red-700"
onClick={() => handleDelete(item.id)}
>
Ya
</Button>
<Button
variant="text"
size="sm"
className="text-[10px] text-neutral-500 p-0 font-normal hover:bg-transparent"
onClick={() => setDeleteId(null)}
>
Batal
</Button>
</div>
) : (
<Button
variant="text"
size="sm"
className="text-[10px] text-red-500 p-0 font-normal hover:bg-transparent hover:text-red-700"
onClick={() => setDeleteId(item.id)}
>
Delete
</Button>
)}
</div>
</div>
<img src="gacha-clip.webp" alt={item.name} />
</div>
))}
</div>
</section>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Gacha Items</CardTitle>
<CardDescription>
Daftar item yang tersedia di gacha
</CardDescription>
</div>
<Button
size="md"
onClick={() => navigate({ to: '/dashboard/create' })}
>
<Plus className="size-4" />
Tambah Item
</Button>
</div>
</CardHeader>
<CardContent>
{gachaItems.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Belum ada item gacha.
</p>
) : (
<ul className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{gachaItems.map((item) => (
<li
key={item.id}
className="flex items-start justify-between gap-3 rounded-md border border-neutral-200 p-4 transition-colors hover:border-primary-200 hover:bg-primary-50/40"
>
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-semibold text-primary-700">
{item.name}
</h3>
<p className="mt-0.5 font-mono text-xs text-muted-foreground">
{item.id}
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="text" size="icon" aria-label="Actions">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() =>
navigate({
to: '/dashboard/$id',
params: { id: item.id },
})
}
>
<Pencil />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => setDeleteId(item.id)}
>
<Trash2 />
<span>Hapus</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<img
src="gacha.webp"
alt=""
className="rounded-lg hidden xl:block xl:min-w-[436px] h-auto object-cover"
/>
</div>
</main>
</Fragment>
)
<DeleteConfirmDialog
open={!!deleteId}
onOpenChange={(o) => !o && setDeleteId(null)}
onConfirm={() => deleteId && handleDelete(deleteId)}
title="Hapus item gacha ini?"
/>
</BackofficeWrapper>
);
}
@@ -1,62 +1,91 @@
import { createFileRoute } from '@tanstack/react-router'
import { SearchOutlined } from '@ant-design/icons'
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms'
import { BackofficeWrapper, DataTable } from '@imphnen-frontend-service/ui/organisms'
import { cn, For } from '@imphnen-frontend-service/utils'
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from '@tanstack/react-table'
import { ReactElement, useState } from 'react'
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service'
import { createFileRoute } from '@tanstack/react-router';
import * as React from 'react';
import { Search, MessageSquare } from 'lucide-react';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@imphnen-frontend-service/ui/atoms';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { cn } from '@imphnen-frontend-service/utils';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service';
import {
SelectAllCheckbox,
RowSelectCheckbox,
} from '../../components/list-helpers';
const TABS = {
MENTORING: 'Mentoring',
PLATFORM: 'Platform'
} as const
type Tabs = typeof TABS[keyof typeof TABS]
export const Route = createFileRoute('/_authenticated/feedback-review-dimentorin')({
export const Route = createFileRoute(
'/_authenticated/feedback-review-dimentorin'
)({
component: FeedbackReviewDimentorinPage,
})
});
function FeedbackReviewDimentorinPage(): ReactElement {
const [activeTab, setActiveTab] = useState<Tabs>(TABS.MENTORING)
function FeedbackReviewDimentorinPage() {
const [activeTab, setActiveTab] = React.useState<'mentoring' | 'platform'>(
'mentoring'
);
const [ratingFilter, setRatingFilter] = React.useState<string>('all');
const [statusFilter, setStatusFilter] = React.useState<string>('all');
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
const [rowSelection, setRowSelection] =
React.useState<RowSelectionState>({});
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
})
pageSize: 10,
});
const { data: sessionsData, isLoading } = useMySessions(
activeTab === TABS.MENTORING ? { status: 'completed' } : undefined
)
activeTab === 'mentoring' ? { status: 'completed' } : undefined
);
const sessions: TSessionListItem[] = activeTab === TABS.MENTORING
? (sessionsData?.sessions ?? [])
: []
const totalItems = activeTab === TABS.MENTORING
? (sessionsData?.total ?? sessions.length)
: 0
const allSessions: TSessionListItem[] =
activeTab === 'mentoring' ? (sessionsData?.sessions ?? []) : [];
const sessions = React.useMemo(() => {
return allSessions.filter((s) => {
if (statusFilter !== 'all') {
const hasRating = !!s.rating;
if (statusFilter === 'done' && !hasRating) return false;
if (statusFilter === 'todo' && hasRating) return false;
}
if (ratingFilter !== 'all' && String(s.rating ?? '') !== ratingFilter)
return false;
return true;
});
}, [allSessions, statusFilter, ratingFilter]);
const totalItems =
activeTab === 'mentoring' ? (sessionsData?.total ?? sessions.length) : 0;
const columns: ColumnDef<TSessionListItem>[] = [
{
id: 'select',
meta: { cellClassName: cn('w-20') },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
meta: { cellClassName: cn('w-10') },
header: ({ table }) => <SelectAllCheckbox table={table} />,
cell: ({ row }) => <RowSelectCheckbox row={row} />,
},
{
id: 'name',
@@ -81,39 +110,29 @@ function FeedbackReviewDimentorinPage(): ReactElement {
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const hasRating = !!row.original.rating
const hasRating = !!row.original.rating;
return (
<div className={`py-2 px-4 rounded-md text-center ${hasRating ? 'bg-success-200 text-success-500' : 'bg-primary-200 text-primary-500'}`}>
<Badge variant={hasRating ? 'success' : 'info'}>
{hasRating ? 'Done' : 'To Do'}
</div>
)
</Badge>
);
},
},
{
header: 'Action',
meta: { cellClassName: cn('w-72') },
cell: () => (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation()
}}
className="flex items-center gap-2 w-max"
>
<SearchOutlined className="text-[16px]" /> Lihat Feedback
<Button variant="secondary" size="sm">
<MessageSquare className="size-3.5" />
Lihat Feedback
</Button>
),
},
]
];
const table = useReactTable({
data: sessions,
columns,
state: {
pagination,
rowSelection,
},
state: { pagination, rowSelection },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
@@ -121,64 +140,85 @@ function FeedbackReviewDimentorinPage(): ReactElement {
onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true,
})
});
return (
<BackofficeWrapper title="Dimentorin.dev">
<div className="mb-8 flex justify-between items-center">
<h1 className="text-p1 font-semibold text-neutral-700">Feedback</h1>
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
<For data={Object.values(TABS)}>
{(tab) => (
<Button
key={tab}
variant="text"
className={cn('px-3 py-2 capitalize', activeTab === tab && 'bg-white')}
onClick={() => {
setActiveTab(tab)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
>
{tab}
</Button>
)}
</For>
</div>
</div>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-5 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama mentor/mentee"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
<BackofficeWrapper
title="Feedback & Review"
description="Review feedback dari mentoring & platform"
>
<Card>
<CardContent className="pt-6">
<Tabs
value={activeTab}
onValueChange={(v) => {
setActiveTab(v as 'mentoring' | 'platform');
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<TabsList>
<TabsTrigger value="mentoring">Mentoring</TabsTrigger>
<TabsTrigger value="platform">Platform</TabsTrigger>
</TabsList>
<div className="flex flex-col gap-2 sm:flex-row">
<div className="relative w-full sm:w-72">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Cari nama mentor/mentee…"
/>
</div>
<Select value={ratingFilter} onValueChange={setRatingFilter}>
<SelectTrigger className="w-28">
<SelectValue placeholder="Rating" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Rating</SelectItem>
<SelectItem value="4.5">4.5</SelectItem>
<SelectItem value="5">5</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-28">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="done">Done</SelectItem>
<SelectItem value="todo">To Do</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<Select>
<option disabled>Rating</option>
<option value="4.5">4.5</option>
<option value="5">5</option>
</Select>
<Select>
<option disabled>Status</option>
<option value="done">Done</option>
<option value="todo">To Do</option>
</Select>
</div>
{isLoading ? (
<div className="text-center py-8 text-neutral-400">Loading...</div>
) : activeTab === TABS.PLATFORM ? (
<div className="text-center py-8 text-neutral-400">
Platform feedback tidak tersedia
</div>
) : (
<DataTable data={sessions} columns={columns} table={table} />
)}
</section>
<TabsContent value="mentoring" className="mt-4">
{isLoading ? (
<div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : (
<DataTable
data={sessions}
columns={columns}
table={table}
manualPagination
pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</TabsContent>
<TabsContent value="platform" className="mt-4">
<div className="py-10 text-center text-sm text-muted-foreground">
Platform feedback belum tersedia
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</BackofficeWrapper>
)
);
}
@@ -1,14 +1,17 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import * as React from 'react'
import { Fragment, useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
import {
SearchOutlined,
EditOutlined,
DeleteOutlined,
PlusOutlined,
} from '@ant-design/icons'
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
Button,
Card,
CardContent,
CardHeader,
Input,
} from '@imphnen-frontend-service/ui/atoms';
import {
DataTable,
BackofficeWrapper,
} from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
@@ -16,143 +19,101 @@ import {
PaginationState,
useReactTable,
RowSelectionState,
} from '@tanstack/react-table'
} from '@tanstack/react-table';
import {
useGachaItemList,
useDeleteGachaItem,
TGachaItemDto,
} from '@imphnen-frontend-service/service'
import { toast } from 'sonner'
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
import {
SelectAllCheckbox,
RowSelectCheckbox,
DeleteConfirmDialog,
} from '../../components/list-helpers';
export const Route = createFileRoute('/_authenticated/gacha-roll')({
component: GachaRollPage,
})
});
function GachaRollPage() {
const navigate = useNavigate()
const [search, setSearch] = useState('')
const [deleteId, setDeleteId] = useState<string | null>(null)
const navigate = useNavigate();
const [search, setSearch] = React.useState('');
const [deleteId, setDeleteId] = React.useState<string | null>(null);
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
})
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
pageSize: 10,
});
const [rowSelection, setRowSelection] =
React.useState<RowSelectionState>({});
const { data: itemsData, isLoading } = useGachaItemList({
search,
page: pagination.pageIndex + 1,
per_page: pagination.pageSize,
})
const deleteItem = useDeleteGachaItem()
});
const deleteItem = useDeleteGachaItem();
const items: TGachaItemDto[] = itemsData?.data ?? []
const totalItems = itemsData?.meta?.total ?? items.length
const items: TGachaItemDto[] = itemsData?.data ?? [];
const totalItems = itemsData?.meta?.total ?? items.length;
const handleDelete = async (id: string) => {
try {
await deleteItem.mutateAsync(id)
toast.success('Item berhasil dihapus')
setDeleteId(null)
await deleteItem.mutateAsync(id);
toast.success('Item berhasil dihapus');
setDeleteId(null);
} catch (error) {
console.log(error)
toast.error('Item gagal dihapus')
console.log(error);
toast.error('Item gagal dihapus');
}
}
};
const columns: ColumnDef<TGachaItemDto>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'No',
accessorKey: 'id',
},
{
header: 'Nama Item',
accessorKey: 'name',
header: ({ table }) => <SelectAllCheckbox table={table} />,
cell: ({ row }) => <RowSelectCheckbox row={row} />,
},
{ header: 'No', accessorKey: 'id' },
{ header: 'Nama Item', accessorKey: 'name' },
{
header: 'Action',
cell: ({ row }) => (
<div className="flex gap-[8px]">
<div className="flex items-center gap-2">
<Button
variant="primary"
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation()
navigate({ to: '/gacha-roll/$id', params: { id: row.original.id } })
e.stopPropagation();
navigate({
to: '/gacha-roll/$id',
params: { id: row.original.id },
});
}}
className="flex items-center gap-2"
>
<EditOutlined /> Update
<Pencil className="size-3.5" />
Update
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
setDeleteId(row.original.id);
}}
>
<Trash2 className="size-3.5" />
Delete
</Button>
{deleteId === row.original.id ? (
<div className="flex items-center gap-2">
<span className="text-label2 text-neutral-500">Yakin?</span>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleDelete(row.original.id)
}}
>
Ya
</Button>
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation()
setDeleteId(null)
}}
>
Batal
</Button>
</div>
) : (
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation()
setDeleteId(row.original.id)
}}
className="flex items-center gap-2"
>
<DeleteOutlined /> Delete
</Button>
)}
</div>
),
},
]
];
const table = useReactTable({
data: items,
columns,
state: {
pagination,
rowSelection,
},
state: { pagination, rowSelection },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
@@ -160,48 +121,61 @@ function GachaRollPage() {
onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true,
})
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Gacha Roll</h1>
</header>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<BackofficeWrapper
title="Gacha Roll"
description="Kelola item hadiah gacha"
>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Cari berdasarkan nama item"
className="pl-12 w-full max-h-full"
placeholder="Cari nama item…"
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex gap-3 text-nowrap"
onClick={() => navigate({ to: '/gacha-roll/create' })}
>
<PlusOutlined />
Tambah Item
</Button>
</div>
<Button
onClick={() => navigate({ to: '/gacha-roll/create' })}
size="md"
>
<Plus className="size-4" />
Tambah Item
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-neutral-400">Loading...</div>
<div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : (
<DataTable data={items} columns={columns} table={table} />
<DataTable
data={items}
columns={columns}
table={table}
manualPagination
pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</section>
</main>
</Fragment>
)
</CardContent>
</Card>
<DeleteConfirmDialog
open={!!deleteId}
onOpenChange={(o) => !o && setDeleteId(null)}
onConfirm={() => deleteId && handleDelete(deleteId)}
title="Hapus item gacha ini?"
/>
</BackofficeWrapper>
);
}
@@ -1,61 +1,82 @@
import { createFileRoute } from '@tanstack/react-router'
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms'
import { FC, ReactElement } from 'react'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router';
import * as React from 'react';
import { UsersRound, UserCog, ClipboardCheck } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import {
Card,
CardContent,
} from '@imphnen-frontend-service/ui/atoms';
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import {
getAdminUsers,
getAdminTeams,
getAdminSubmissions,
} from '@imphnen-frontend-service/service'
} from '@imphnen-frontend-service/service';
export const Route = createFileRoute('/_authenticated/hackathon-dashboard')({
component: HackathonDashboardPage,
})
});
type StatCardProps = {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: React.ReactNode;
};
function StatCard({ icon: Icon, label, value }: StatCardProps) {
return (
<Card>
<CardContent className="flex items-center gap-4 pt-6">
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary-100 text-primary-600">
<Icon className="size-5" />
</div>
<div className="flex flex-col">
<span className="text-2xl font-semibold leading-tight text-foreground">
{value}
</span>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
</CardContent>
</Card>
);
}
function HackathonDashboardPage() {
const { data: usersData } = useQuery({
queryKey: ['admin-users-count'],
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
})
});
const { data: teamsData } = useQuery({
queryKey: ['admin-teams-count'],
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
})
});
const { data: submissionsData } = useQuery({
queryKey: ['admin-submissions-count'],
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
})
});
const totalParticipants = usersData?.meta?.total_data ?? '??'
const totalTeams = teamsData?.meta?.total_data ?? '??'
const totalSubmissions = submissionsData?.meta?.total_data ?? '??'
const totalParticipants = usersData?.meta?.total_data ?? '—';
const totalTeams = teamsData?.meta?.total_data ?? '—';
const totalSubmissions = submissionsData?.meta?.total_data ?? '—';
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
<section className="grid grid-cols-5 gap-5">
<div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
{totalParticipants}
</h3>
<p className="text-neutral-400 text-p3">Total Participants</p>
</div>
<div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
{totalTeams}
</h3>
<p className="text-neutral-400 text-p3">Total Teams</p>
</div>
<div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
{totalSubmissions}
</h3>
<p className="text-neutral-400 text-p3">Total Project Submitted</p>
</div>
<BackofficeWrapper
title="Hackathon Dashboard"
description="IMPHNEN x Kolosal.ai Hackathon 2025"
>
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
icon={UserCog}
label="Total Participants"
value={totalParticipants}
/>
<StatCard icon={UsersRound} label="Total Teams" value={totalTeams} />
<StatCard
icon={ClipboardCheck}
label="Total Project Submitted"
value={totalSubmissions}
/>
</section>
</BackofficeWrapper>
)
);
}
@@ -1,34 +1,27 @@
import { createFileRoute } from '@tanstack/react-router'
import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react'
import SubmissionModal from './_components/hackathon-submissions/submission-modal'
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import { Search, Eye } from 'lucide-react';
import SubmissionModal from './_components/hackathon-submissions/submission-modal';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms'
import { ColumnDef } from '@tanstack/react-table'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { cn } from '@imphnen-frontend-service/utils'
} from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table';
import {
SearchOutlined,
FilterOutlined,
LoadingOutlined,
EyeOutlined,
} from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
Badge,
Button,
Card,
CardContent,
CardHeader,
Input,
} from '@imphnen-frontend-service/ui/atoms';
import { useQuery } from '@tanstack/react-query';
import {
getAdminSubmissions,
TAdminSubmissionItem,
} from '@imphnen-frontend-service/service'
import { useNavigate } from '@tanstack/react-router'
} from '@imphnen-frontend-service/service';
type SubmissionType = TAdminSubmissionItem
type SubmissionType = TAdminSubmissionItem;
export const Route = createFileRoute('/_authenticated/hackathon-submissions')({
component: HackathonSubmissionsPage,
@@ -38,20 +31,20 @@ export const Route = createFileRoute('/_authenticated/hackathon-submissions')({
per_page: Number(search.per_page) || 10,
status: (search.status as string) || 'all',
}),
})
});
function HackathonSubmissionsPage() {
const searchParams = Route.useSearch()
const navigate = useNavigate()
const currentPage = Math.max(1, searchParams.page)
const searchQuery = searchParams.search || ''
const perPage = searchParams.per_page || 10
const statusFilter = searchParams.status || 'all'
const searchParams = Route.useSearch();
const navigate = useNavigate();
const currentPage = Math.max(1, searchParams.page);
const searchQuery = searchParams.search || '';
const perPage = searchParams.per_page || 10;
const statusFilter = searchParams.status || 'all';
const [showSubmissionModal, setShowSubmissionModal] = useState(false)
const [showSubmissionModal, setShowSubmissionModal] = React.useState(false);
const [selectedSubmission, setSelectedSubmission] =
useState<SubmissionType | null>(null)
const [globalFilter, setGlobalFilter] = useState(searchQuery)
React.useState<SubmissionType | null>(null);
const [globalFilter, setGlobalFilter] = React.useState(searchQuery);
const {
data: submissionsResponse,
@@ -74,12 +67,12 @@ function HackathonSubmissionsPage() {
}),
staleTime: 30000,
gcTime: 5 * 60 * 1000,
})
});
const totalData = submissionsResponse?.meta?.total_data || 0
const totalPages = submissionsResponse?.meta?.total_page || 1
const totalData = submissionsResponse?.meta?.total_data || 0;
const totalPages = submissionsResponse?.meta?.total_page || 1;
const handlePageChange = useCallback(
const handlePageChange = React.useCallback(
(newPage: number) => {
navigate({
search: {
@@ -88,23 +81,23 @@ function HackathonSubmissionsPage() {
search: searchQuery || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
} as any,
})
window.scrollTo({ top: 0, behavior: 'smooth' })
});
window.scrollTo({ top: 0, behavior: 'smooth' });
},
[navigate, perPage, searchQuery, statusFilter]
)
);
useEffect(() => {
React.useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
navigate({ search: { page: totalPages } as any })
navigate({ search: { page: totalPages } as any });
}
}, [currentPage, totalPages, navigate, isLoading])
}, [currentPage, totalPages, navigate, isLoading]);
useEffect(() => {
setGlobalFilter(searchQuery)
}, [searchQuery])
React.useEffect(() => {
setGlobalFilter(searchQuery);
}, [searchQuery]);
const handleSearch = useCallback(() => {
const handleSearch = React.useCallback(() => {
navigate({
search: {
page: 1,
@@ -112,56 +105,29 @@ function HackathonSubmissionsPage() {
search: globalFilter.trim() || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
} as any,
})
}, [globalFilter, navigate, perPage, statusFilter])
});
}, [globalFilter, navigate, perPage, statusFilter]);
const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch()
}
},
[handleSearch]
)
const filteredData = React.useMemo<SubmissionType[]>(() => {
return (
((submissionsResponse?.data as any)?.data as SubmissionType[]) ??
(submissionsResponse?.data as SubmissionType[]) ??
[]
);
}, [submissionsResponse]);
const handlePerPageChange = useCallback(
(newPerPage: number) => {
navigate({
search: {
page: 1,
per_page: newPerPage,
search: searchQuery || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
} as any,
})
},
[navigate, searchQuery, statusFilter]
)
const statusVariants: Record<string, 'success' | 'warning' | 'secondary'> = {
submitted: 'success',
pending: 'warning',
};
const handleShowSubmissionModal = useCallback(
(submission: SubmissionType) => {
setSelectedSubmission(submission)
setShowSubmissionModal(true)
},
[]
)
const handleCloseSubmissionModal = useCallback(() => {
setShowSubmissionModal(false)
setSelectedSubmission(null)
}, [])
const filteredData = useMemo(() => {
return submissionsResponse?.data?.data || submissionsResponse?.data || []
}, [submissionsResponse])
const columns: ColumnDef<SubmissionType>[] = useMemo(
const columns: ColumnDef<SubmissionType>[] = React.useMemo(
() => [
{
accessorKey: 'project_name',
header: 'Project Name',
cell: ({ row }) => (
<span className="font-medium text-neutral-900">
<span className="font-medium text-foreground">
{row.original.project_name}
</span>
),
@@ -171,7 +137,7 @@ function HackathonSubmissionsPage() {
accessorKey: 'team_id',
header: 'Team ID',
cell: ({ row }) => (
<span className="text-sm text-neutral-700 font-mono">
<span className="font-mono text-xs text-muted-foreground">
{row.original.team_id}
</span>
),
@@ -180,30 +146,21 @@ function HackathonSubmissionsPage() {
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const status = row.original.status
return (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
status === 'submitted'
? 'bg-success-100 text-success-800'
: status === 'pending'
? 'bg-orange-100 text-orange-800'
: 'bg-neutral-100 text-neutral-700'
)}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
)
},
cell: ({ row }) => (
<Badge
variant={statusVariants[row.original.status] ?? 'secondary'}
className="capitalize"
>
{row.original.status}
</Badge>
),
enableSorting: true,
},
{
accessorKey: 'submitted_at',
header: 'Submitted',
cell: ({ row }) => (
<span className="text-neutral-900 text-sm">
<span className="text-sm text-foreground">
{new Date(row.original.submitted_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
@@ -217,109 +174,87 @@ function HackathonSubmissionsPage() {
{
id: 'actions',
header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<Button
variant="primary"
variant="secondary"
size="sm"
className="flex items-center gap-2 text-sm px-4 py-2"
onClick={() => handleShowSubmissionModal(row.original)}
onClick={() => {
setSelectedSubmission(row.original);
setShowSubmissionModal(true);
}}
>
<EyeOutlined className="text-sm" />
<Eye className="size-3.5" />
View
</Button>
),
enableSorting: false,
},
],
[handleShowSubmissionModal]
)
[]
);
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
Project Submissions
</h1>
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center justify-between">
<div className="flex flex-wrap gap-3 items-center">
<div className="relative">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
<input
type="text"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
placeholder="Search by project name..."
<BackofficeWrapper
title="Project Submissions"
description="IMPHNEN x Kolosal.ai Hackathon 2025"
>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Cari nama project…"
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<div className="relative">
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="py-12 text-center text-sm text-muted-foreground">
Memuat submissions…
</div>
{}
{
}
</div>
</div>
{}
{
}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
<span className="ml-3 text-neutral-600">
Loading submissions...
</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} submissions (Page{' '}
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
)}
) : filteredData.length > 0 ? (
<>
<div className="mb-3 text-xs text-muted-foreground">
Menampilkan {filteredData.length} dari {totalData} submissions
(page {currentPage} / {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">Updating…</span>
)}
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="py-12 text-center text-sm text-muted-foreground">
Tidak ada submissions.
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination={true}
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="text-center py-12 text-neutral-500">
No submissions found. Try adjusting your filters.
</div>
)}
</section>
)}
</CardContent>
</Card>
{selectedSubmission && (
<SubmissionModal
isOpen={showSubmissionModal}
onClose={handleCloseSubmissionModal}
onClose={() => {
setShowSubmissionModal(false);
setSelectedSubmission(null);
}}
submission={selectedSubmission}
/>
)}
</BackofficeWrapper>
)
);
}
@@ -1,37 +1,30 @@
import { createFileRoute } from '@tanstack/react-router'
import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react'
import ModalTeamDetail from './_components/hackathon-teams/modal-team-detail-new'
import { CityFilterSelect } from '../../components/city-filter-select'
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import { Search, Plus, Users as TeamIcon, Pencil, X } from 'lucide-react';
import ModalTeamDetail from './_components/hackathon-teams/modal-team-detail-new';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms'
import { ColumnDef } from '@tanstack/react-table'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { cn } from '@imphnen-frontend-service/utils'
} from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table';
import {
EditOutlined,
TeamOutlined,
SearchOutlined,
FilterOutlined,
PlusOutlined,
LoadingOutlined,
} from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
Avatar,
AvatarFallback,
AvatarImage,
Badge,
Button,
Card,
CardContent,
CardHeader,
Input,
} from '@imphnen-frontend-service/ui/atoms';
import { useQuery } from '@tanstack/react-query';
import {
getAdminTeams,
TAdminTeamItem,
} from '@imphnen-frontend-service/service'
import { useNavigate } from '@tanstack/react-router'
} from '@imphnen-frontend-service/service';
type TeamType = TAdminTeamItem
type TeamType = TAdminTeamItem;
export const Route = createFileRoute('/_authenticated/hackathon-teams')({
component: HackathonTeamsPage,
@@ -40,22 +33,21 @@ export const Route = createFileRoute('/_authenticated/hackathon-teams')({
search: (search.search as string) || '',
per_page: Number(search.per_page) || 10,
}),
})
});
function HackathonTeamsPage() {
const searchParams = Route.useSearch()
const navigate = useNavigate()
const currentPage = Math.max(1, searchParams.page)
const searchQuery = searchParams.search || ''
const perPage = searchParams.per_page || 10
const [showDetailModal, setShowDetailModal] = useState(false)
const [showNewTeamModal, setShowNewTeamModal] = useState(false)
const [selectedTeam, setSelectedTeam] = useState<TeamType | null>(null)
useState<TeamType | null>(null)
const [globalFilter, setGlobalFilter] = useState(searchQuery)
const searchParams = Route.useSearch();
const navigate = useNavigate();
const currentPage = Math.max(1, searchParams.page);
const searchQuery = searchParams.search || '';
const perPage = searchParams.per_page || 10;
const [visibilityFilter, setVisibilityFilter] = useState('all')
const [cityFilter, setCityFilter] = useState('all')
const [showDetailModal, setShowDetailModal] = React.useState(false);
const [showNewTeamModal, setShowNewTeamModal] = React.useState(false);
const [selectedTeam, setSelectedTeam] = React.useState<TeamType | null>(null);
const [globalFilter, setGlobalFilter] = React.useState(searchQuery);
const [visibilityFilter, setVisibilityFilter] = React.useState('all');
const [cityFilter, setCityFilter] = React.useState('all');
const {
data: teamsResponse,
@@ -78,12 +70,12 @@ function HackathonTeamsPage() {
}),
staleTime: 30000,
gcTime: 5 * 60 * 1000,
})
});
const totalData = teamsResponse?.meta?.total_data || 0
const totalPages = teamsResponse?.meta?.total_page || 1
const totalData = teamsResponse?.meta?.total_data || 0;
const totalPages = teamsResponse?.meta?.total_page || 1;
const handlePageChange = useCallback(
const handlePageChange = React.useCallback(
(newPage: number) => {
navigate({
search: {
@@ -91,142 +83,97 @@ function HackathonTeamsPage() {
per_page: perPage !== 10 ? perPage : undefined,
search: searchQuery || undefined,
} as any,
})
window.scrollTo({ top: 0, behavior: 'smooth' })
});
window.scrollTo({ top: 0, behavior: 'smooth' });
},
[navigate, perPage, searchQuery]
)
);
useEffect(() => {
React.useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
navigate({ search: { page: totalPages } as any })
navigate({ search: { page: totalPages } as any });
}
}, [currentPage, totalPages, navigate, isLoading])
}, [currentPage, totalPages, navigate, isLoading]);
useEffect(() => {
setGlobalFilter(searchQuery)
}, [searchQuery])
React.useEffect(() => {
setGlobalFilter(searchQuery);
}, [searchQuery]);
const handleSearch = useCallback(() => {
const handleSearch = React.useCallback(() => {
navigate({
search: {
page: 1,
per_page: perPage !== 10 ? perPage : undefined,
search: globalFilter.trim() || undefined,
} as any,
})
}, [globalFilter, navigate, perPage])
});
}, [globalFilter, navigate, perPage]);
const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch()
}
},
[handleSearch]
)
const filteredData = React.useMemo<TeamType[]>(() => {
return (
((teamsResponse?.data as any)?.data as TeamType[]) ??
(teamsResponse?.data as TeamType[]) ??
[]
);
}, [teamsResponse]);
const handlePerPageChange = useCallback(
(newPerPage: number) => {
navigate({
search: {
page: 1,
per_page: newPerPage,
search: searchQuery || undefined,
} as any,
})
},
[navigate, searchQuery]
)
const handleShowDetailModal = React.useCallback((team: TeamType) => {
setSelectedTeam(team);
setShowDetailModal(true);
}, []);
const handleShowDetailModal = useCallback((team: TeamType) => {
setSelectedTeam(team)
setShowDetailModal(true)
}, [])
const handleCloseDetailModal = useCallback(() => {
setShowDetailModal(false)
setSelectedTeam(null)
}, [])
const handleShowNewTeamModal = useCallback(() => {
setShowNewTeamModal(true)
}, [])
const handleCloseNewTeamModal = useCallback(() => {
setShowNewTeamModal(false)
}, [])
const filteredData = useMemo(() => {
return teamsResponse?.data?.data || teamsResponse?.data || []
}, [teamsResponse])
const columns: ColumnDef<TeamType>[] = useMemo(
const columns: ColumnDef<TeamType>[] = React.useMemo(
() => [
{
accessorKey: 'name',
header: 'Team',
cell: ({ row }) => {
const team = row.original
return (
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-neutral-100 flex items-center justify-center shrink-0 overflow-hidden">
{team.logo ? (
<img
src={team.logo}
alt={team.name}
className="w-full h-full object-cover"
/>
) : (
<TeamOutlined className="text-neutral-400 text-lg" />
)}
</div>
<div className="min-w-0 flex-1">
<p
className="font-medium text-neutral-900 truncate max-w-sm"
title={team.name}
>
{team.name}
</p>
</div>
cell: ({ row }) => (
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage src={row.original.logo ?? undefined} alt={row.original.name} />
<AvatarFallback>
<TeamIcon className="size-4 text-muted-foreground" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p
className="truncate font-medium text-foreground"
title={row.original.name}
>
{row.original.name}
</p>
</div>
)
},
</div>
),
enableSorting: true,
},
{
accessorKey: 'city',
header: 'City',
cell: ({ row }) => (
<span className="text-neutral-700">{row.original.city}</span>
<span className="text-foreground">{row.original.city}</span>
),
enableSorting: true,
},
{
accessorKey: 'visibility',
header: 'Visibility',
cell: ({ row }) => {
const isPublic = row.original.visibility === 'public'
return (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
isPublic
? 'bg-success-100 text-success-800'
: 'bg-neutral-100 text-neutral-700'
)}
>
{isPublic ? 'Public' : 'Private'}
</span>
)
},
cell: ({ row }) => (
<Badge
variant={
row.original.visibility === 'public' ? 'success' : 'secondary'
}
>
{row.original.visibility === 'public' ? 'Public' : 'Private'}
</Badge>
),
enableSorting: true,
},
{
id: 'leader',
header: 'Leader ID',
cell: ({ row }) => (
<div className="text-sm text-neutral-700 font-mono">
<div className="font-mono text-xs text-muted-foreground">
{row.original.leader_id}
</div>
),
@@ -236,7 +183,7 @@ function HackathonTeamsPage() {
accessorKey: 'created_at',
header: 'Created',
cell: ({ row }) => (
<span className="text-neutral-900 text-sm">
<span className="text-sm text-foreground">
{new Date(row.original.created_at).toLocaleDateString('en-UK', {
year: 'numeric',
month: 'short',
@@ -250,168 +197,127 @@ function HackathonTeamsPage() {
{
id: 'actions',
header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 text-sm px-4 py-2"
onClick={() => handleShowDetailModal(row.original)}
>
<EditOutlined className="text-sm" />
Manage
</Button>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => handleShowDetailModal(row.original)}
>
<Pencil className="size-3.5" />
Manage
</Button>
),
enableSorting: false,
},
],
[handleShowDetailModal]
)
);
const hasActiveFilters = visibilityFilter !== 'all' || cityFilter !== 'all';
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
Team Management
</h1>
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center justify-between">
<div className="flex flex-wrap gap-3 items-center">
<div className="relative">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
<input
type="text"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
placeholder="Search teams by name or city..."
<BackofficeWrapper
title="Hackathon Teams"
description="IMPHNEN x Kolosal.ai Hackathon 2025"
>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Cari nama atau kota…"
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<div className="relative">
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</div>
{}
{
}
{}
{
}
</div>
<div className="flex items-center gap-3">
<Button
variant="primary"
size="md"
className="flex items-center gap-2 px-4 py-2"
onClick={handleShowNewTeamModal}
>
<PlusOutlined className="text-sm" />
<Button onClick={() => setShowNewTeamModal(true)} size="md">
<Plus className="size-4" />
Add Team
</Button>
</div>
</div>
{(visibilityFilter !== 'all' || cityFilter !== 'all') && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
{visibilityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
Visibility: {visibilityFilter}
<button
onClick={() => setVisibilityFilter('all')}
className="text-info-600 hover:text-info-800 cursor-pointer"
>
✕
</button>
</CardHeader>
<CardContent className="space-y-4">
{hasActiveFilters && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">
Active filters:
</span>
)}
{cityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
City: {cityFilter}
<button
onClick={() => setCityFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer"
>
✕
</button>
</span>
)}
<Button
variant="secondary"
size="sm"
onClick={() => {
setVisibilityFilter('all')
setCityFilter('all')
setGlobalFilter('')
}}
className="text-sm text-neutral-600"
>
Clear All
</Button>
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
<span className="ml-3 text-neutral-600">Loading teams...</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} teams (Page{' '}
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
{visibilityFilter !== 'all' && (
<Badge variant="info" className="gap-1">
Visibility: {visibilityFilter}
<button onClick={() => setVisibilityFilter('all')}>
<X className="size-3" />
</button>
</Badge>
)}
{cityFilter !== 'all' && (
<Badge variant="success" className="gap-1">
City: {cityFilter}
<button onClick={() => setCityFilter('all')}>
<X className="size-3" />
</button>
</Badge>
)}
<Button
variant="text"
size="sm"
onClick={() => {
setVisibilityFilter('all');
setCityFilter('all');
setGlobalFilter('');
}}
>
Clear All
</Button>
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination={true}
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="text-center py-12 text-neutral-500">
No teams found. Try adjusting your filters.
</div>
)}
</section>
)}
{isLoading ? (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
Memuat data teams…
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-xs text-muted-foreground">
Menampilkan {filteredData.length} dari {totalData} teams (page{' '}
{currentPage} / {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">Updating…</span>
)}
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="py-12 text-center text-sm text-muted-foreground">
Tidak ada team. Coba ubah filter pencarian.
</div>
)}
</CardContent>
</Card>
<ModalTeamDetail
isOpen={showDetailModal}
onClose={handleCloseDetailModal}
onClose={() => {
setShowDetailModal(false);
setSelectedTeam(null);
}}
team={selectedTeam}
/>
<ModalTeamDetail
isOpen={showNewTeamModal}
onClose={handleCloseNewTeamModal}
onClose={() => setShowNewTeamModal(false)}
team={null}
/>
</BackofficeWrapper>
)
);
}
@@ -1,48 +1,31 @@
import { createFileRoute } from '@tanstack/react-router'
import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react'
import ModalUserDetail from './_components/hackathon-users/modal-user-detail'
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import { Search, Plus, User, Pencil, X } from 'lucide-react';
import ModalUserDetail from './_components/hackathon-users/modal-user-detail';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms'
import { ColumnDef } from '@tanstack/react-table'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { cn } from '@imphnen-frontend-service/utils'
} from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table';
import {
EditOutlined,
UserOutlined,
SearchOutlined,
FilterOutlined,
PlusOutlined,
LoadingOutlined,
} from '@ant-design/icons'
import { CityFilterSelect } from '../../components/city-filter-select'
import { useQuery } from '@tanstack/react-query'
Avatar,
AvatarFallback,
AvatarImage,
Badge,
Button,
Card,
CardContent,
CardHeader,
Input,
} from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import { useQuery } from '@tanstack/react-query';
import {
getAdminUsers,
TAdminUserItem,
} from '@imphnen-frontend-service/service'
import { useNavigate } from '@tanstack/react-router'
} from '@imphnen-frontend-service/service';
type UserType = TAdminUserItem
const skillsOptions = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
]
type UserType = TAdminUserItem;
export const Route = createFileRoute('/_authenticated/hackathon-users')({
component: HackathonUsersPage,
@@ -51,36 +34,28 @@ export const Route = createFileRoute('/_authenticated/hackathon-users')({
search: (search.search as string) || '',
per_page: Number(search.per_page) || 10,
}),
})
});
function HackathonUsersPage() {
const searchParams = Route.useSearch()
const navigate = useNavigate()
const currentPage = Math.max(1, searchParams.page)
const searchQuery = searchParams.search || ''
const perPage = searchParams.per_page || 10
const [showDetailModal, setShowDetailModal] = useState(false)
const [showNewUserModal, setShowNewUserModal] = useState(false)
const [selectedUser, setSelectedUser] = useState<UserType | null>(null)
const [globalFilter, setGlobalFilter] = useState(searchQuery)
const searchParams = Route.useSearch();
const navigate = useNavigate();
const currentPage = Math.max(1, searchParams.page);
const searchQuery = searchParams.search || '';
const perPage = searchParams.per_page || 10;
const [statusFilter, setStatusFilter] = useState('all')
const [cityFilter, setCityFilter] = useState('all')
const [skillsFilter, setSkillsFilter] = useState<string[]>([])
const [showDetailModal, setShowDetailModal] = React.useState(false);
const [showNewUserModal, setShowNewUserModal] = React.useState(false);
const [selectedUser, setSelectedUser] = React.useState<UserType | null>(null);
const [globalFilter, setGlobalFilter] = React.useState(searchQuery);
const [statusFilter, setStatusFilter] = React.useState('all');
const [skillsFilter, setSkillsFilter] = React.useState<string[]>([]);
const {
data: usersResponse,
isLoading,
isFetching,
} = useQuery({
queryKey: [
'admin-users',
currentPage,
perPage,
cityFilter,
statusFilter,
searchQuery,
],
queryKey: ['admin-users', currentPage, perPage, statusFilter, searchQuery],
queryFn: () =>
getAdminUsers({
page: currentPage,
@@ -89,12 +64,12 @@ function HackathonUsersPage() {
}),
staleTime: 30000,
gcTime: 5 * 60 * 1000,
})
});
const totalData = usersResponse?.meta?.total_data || 0
const totalPages = usersResponse?.meta?.total_page || 1
const totalData = usersResponse?.meta?.total_data || 0;
const totalPages = usersResponse?.meta?.total_page || 1;
const handlePageChange = useCallback(
const handlePageChange = React.useCallback(
(newPage: number) => {
navigate({
search: {
@@ -102,112 +77,73 @@ function HackathonUsersPage() {
per_page: perPage !== 10 ? perPage : undefined,
search: searchQuery || undefined,
} as any,
})
window.scrollTo({ top: 0, behavior: 'smooth' })
});
window.scrollTo({ top: 0, behavior: 'smooth' });
},
[navigate, perPage, searchQuery]
)
);
useEffect(() => {
React.useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
navigate({ search: { page: totalPages } as any })
navigate({ search: { page: totalPages } as any });
}
}, [currentPage, totalPages, navigate, isLoading])
}, [currentPage, totalPages, navigate, isLoading]);
useEffect(() => {
setGlobalFilter(searchQuery)
}, [searchQuery])
React.useEffect(() => {
setGlobalFilter(searchQuery);
}, [searchQuery]);
const handleSearch = useCallback(() => {
const handleSearch = React.useCallback(() => {
navigate({
search: {
page: 1,
per_page: perPage !== 10 ? perPage : undefined,
search: globalFilter.trim() || undefined,
} as any,
})
}, [globalFilter, navigate, perPage])
});
}, [globalFilter, navigate, perPage]);
const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch()
}
},
[handleSearch]
)
const handleShowDetailModal = React.useCallback((user: UserType) => {
setSelectedUser(user);
setShowDetailModal(true);
}, []);
const handlePerPageChange = useCallback(
(newPerPage: number) => {
navigate({
search: {
page: 1,
per_page: newPerPage,
search: searchQuery || undefined,
} as any,
})
},
[navigate, searchQuery]
)
const handleShowDetailModal = useCallback((user: UserType) => {
setSelectedUser(user)
setShowDetailModal(true)
}, [])
const handleCloseDetailModal = useCallback(() => {
setShowDetailModal(false)
setSelectedUser(null)
}, [])
const handleShowNewUserModal = useCallback(() => {
setShowNewUserModal(true)
}, [])
const handleCloseNewUserModal = useCallback(() => {
setShowNewUserModal(false)
}, [])
const filteredData = useMemo(() => {
const usersData = usersResponse?.data?.data || usersResponse?.data || []
return usersData.filter((user: UserType) => {
const filteredData = React.useMemo(() => {
const usersData: UserType[] =
((usersResponse?.data as any)?.data as UserType[]) ??
(usersResponse?.data as UserType[]) ??
[];
return usersData.filter((user) => {
if (statusFilter !== 'all') {
const isActive = statusFilter === 'active'
if (user.is_active !== isActive) return false
const isActive = statusFilter === 'active';
if (user.is_active !== isActive) return false;
}
if (skillsFilter.length > 0) {
const userSkills = user.skills || []
const userSkills = user.skills || [];
const hasMatchingSkill = skillsFilter.some((skill) =>
userSkills.includes(skill)
)
if (!hasMatchingSkill) return false
);
if (!hasMatchingSkill) return false;
}
return true;
});
}, [usersResponse, statusFilter, skillsFilter]);
return true
})
}, [usersResponse, statusFilter, skillsFilter])
const columns: ColumnDef<UserType>[] = useMemo(
const columns: ColumnDef<UserType>[] = React.useMemo(
() => [
{
accessorKey: 'fullname',
header: 'User',
cell: ({ row }) => (
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
{row.original.avatar ? (
<img
src={row.original.avatar}
alt={row.original.fullname}
className="w-full h-full object-cover"
/>
) : (
<UserOutlined className="text-neutral-500 text-lg" />
)}
</div>
<Avatar className="size-9">
<AvatarImage src={row.original.avatar ?? undefined} alt={row.original.fullname} />
<AvatarFallback>
<User className="size-4 text-muted-foreground" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="font-medium text-neutral-900 truncate">
<p className="truncate font-medium text-foreground">
{row.original.fullname}
</p>
</div>
@@ -219,30 +155,22 @@ function HackathonUsersPage() {
accessorKey: 'skills',
header: 'Skills',
cell: ({ row }) => {
const skills = row.original.skills || []
const skills = row.original.skills || [];
if (skills.length === 0) {
return <span className="text-muted-foreground">-</span>;
}
return (
<div className="flex flex-wrap gap-1 max-w-xs">
{skills.length > 0 ? (
<>
{skills.slice(0, 2).map((skill, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
>
{skill.replace(' Developer', '').replace(' Engineer', '')}
</span>
))}
{skills.length > 2 && (
<span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700">
+{skills.length - 2}
</span>
)}
</>
) : (
<span className="text-neutral-400">-</span>
<div className="flex flex-wrap gap-1">
{skills.slice(0, 2).map((skill, index) => (
<Badge key={index} variant="success">
{skill.replace(' Developer', '').replace(' Engineer', '')}
</Badge>
))}
{skills.length > 2 && (
<Badge variant="secondary">+{skills.length - 2}</Badge>
)}
</div>
)
);
},
enableSorting: false,
},
@@ -250,7 +178,7 @@ function HackathonUsersPage() {
accessorKey: 'location',
header: 'Location',
cell: ({ row }) => (
<span className="text-neutral-700">{row.original.location}</span>
<span className="text-foreground">{row.original.location}</span>
),
enableSorting: true,
},
@@ -259,16 +187,16 @@ function HackathonUsersPage() {
header: 'Status',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<div
<span
className={cn(
'w-2 h-2 rounded-full',
'size-2 rounded-full',
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
)}
/>
<span
className={cn(
'text-sm font-medium',
row.original.is_active ? 'text-success-700' : 'text-neutral-500'
row.original.is_active ? 'text-success-700' : 'text-muted-foreground'
)}
>
{row.original.is_active ? 'Active' : 'Inactive'}
@@ -277,18 +205,18 @@ function HackathonUsersPage() {
),
enableSorting: true,
sortingFn: (rowA, rowB) => {
const aActive = rowA.original.is_active
const bActive = rowB.original.is_active
if (aActive && !bActive) return -1
if (!aActive && bActive) return 1
return 0
const a = rowA.original.is_active;
const b = rowB.original.is_active;
if (a && !b) return -1;
if (!a && b) return 1;
return 0;
},
},
{
accessorKey: 'created_at',
header: 'Joined',
cell: ({ row }) => (
<span className="text-neutral-900 text-sm">
<span className="text-sm text-foreground">
{new Date(row.original.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
@@ -302,194 +230,135 @@ function HackathonUsersPage() {
{
id: 'actions',
header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 text-sm px-4 py-2"
onClick={() => handleShowDetailModal(row.original)}
>
<EditOutlined className="text-sm" />
Manage
</Button>
{
}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => handleShowDetailModal(row.original)}
>
<Pencil className="size-3.5" />
Manage
</Button>
),
enableSorting: false,
},
],
[handleShowDetailModal]
)
);
const hasActiveFilters = statusFilter !== 'all' || skillsFilter.length > 0;
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
User Management
</h1>
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center justify-between">
<div className="flex flex-wrap gap-3 items-center">
<div className="relative">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
<input
type="text"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
placeholder="Search users by name or location..."
<BackofficeWrapper
title="Hackathon Users"
description="IMPHNEN x Kolosal.ai Hackathon 2025"
>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Cari nama atau lokasi…"
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<div className="relative">
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</div>
{}
{
}
{}
{
}
{}
{
}
</div>
<div className="flex items-center gap-3">
<Button
variant="primary"
size="md"
className="flex items-center gap-2 px-4 py-2"
onClick={handleShowNewUserModal}
>
<PlusOutlined className="text-sm" />
<Button onClick={() => setShowNewUserModal(true)} size="md">
<Plus className="size-4" />
Add User
</Button>
</div>
</div>
{(skillsFilter.length > 0 ||
statusFilter !== 'all' ||
cityFilter !== 'all') && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
{statusFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
Status: {statusFilter}
<button
onClick={() => setStatusFilter('all')}
className="text-info-600 hover:text-info-800 cursor-pointer"
>
✕
</button>
</CardHeader>
<CardContent className="space-y-4">
{hasActiveFilters && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">
Active filters:
</span>
)}
{cityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
City: {cityFilter}
<button
onClick={() => setCityFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer"
>
✕
</button>
</span>
)}
{skillsFilter.map((skill) => (
<span
key={skill}
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
>
{skill.replace(' Developer', '').replace(' Engineer', '')}
<button
onClick={() =>
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
}
className="text-purple-600 hover:text-purple-800 cursor-pointer"
>
✕
</button>
</span>
))}
<Button
variant="secondary"
size="sm"
onClick={() => {
setStatusFilter('all')
setCityFilter('all')
setSkillsFilter([])
setGlobalFilter('')
}}
className="text-sm text-neutral-600"
>
Clear All
</Button>
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
<span className="ml-3 text-neutral-600">Loading users...</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} users (Page{' '}
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
{statusFilter !== 'all' && (
<Badge variant="info" className="gap-1">
Status: {statusFilter}
<button
onClick={() => setStatusFilter('all')}
aria-label="Clear status filter"
>
<X className="size-3" />
</button>
</Badge>
)}
{skillsFilter.map((skill) => (
<Badge key={skill} variant="secondary" className="gap-1">
{skill.replace(' Developer', '').replace(' Engineer', '')}
<button
onClick={() =>
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
}
aria-label={`Clear ${skill} filter`}
>
<X className="size-3" />
</button>
</Badge>
))}
<Button
variant="text"
size="sm"
onClick={() => {
setStatusFilter('all');
setSkillsFilter([]);
setGlobalFilter('');
}}
>
Clear All
</Button>
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination={true}
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="text-center py-12 text-neutral-500">
No users found. Try adjusting your filters.
</div>
)}
</section>
)}
{isLoading ? (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
<span>Memuat data users…</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-xs text-muted-foreground">
Menampilkan {filteredData.length} dari {totalData} users (page{' '}
{currentPage} / {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">Updating…</span>
)}
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="py-12 text-center text-sm text-muted-foreground">
Tidak ada user. Coba ubah filter pencarian.
</div>
)}
</CardContent>
</Card>
<ModalUserDetail
isOpen={showDetailModal}
onClose={handleCloseDetailModal}
onClose={() => {
setShowDetailModal(false);
setSelectedUser(null);
}}
user={selectedUser}
/>
<ModalUserDetail
isOpen={showNewUserModal}
onClose={handleCloseNewUserModal}
onClose={() => setShowNewUserModal(false)}
user={null}
/>
</BackofficeWrapper>
)
);
}
@@ -1,13 +1,17 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { Fragment, useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
import {
SearchOutlined,
EditOutlined,
DeleteOutlined,
PlusOutlined,
} from '@ant-design/icons'
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
Button,
Card,
CardContent,
CardHeader,
Input,
} from '@imphnen-frontend-service/ui/atoms';
import {
DataTable,
BackofficeWrapper,
} from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
@@ -15,144 +19,101 @@ import {
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table'
} from '@tanstack/react-table';
import {
usePermissionList,
useDeletePermission,
TPermissionItem,
} from '@imphnen-frontend-service/service'
import React from 'react'
import { toast } from 'sonner'
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
import {
SelectAllCheckbox,
RowSelectCheckbox,
DeleteConfirmDialog,
} from '../../components/list-helpers';
export const Route = createFileRoute('/_authenticated/permissions')({
component: PermissionsPage,
})
});
function PermissionsPage() {
const navigate = useNavigate()
const [search, setSearch] = useState('')
const [deleteId, setDeleteId] = useState<string | null>(null)
const navigate = useNavigate();
const [search, setSearch] = React.useState('');
const [deleteId, setDeleteId] = React.useState<string | null>(null);
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
})
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
pageSize: 10,
});
const [rowSelection, setRowSelection] =
React.useState<RowSelectionState>({});
const { data: permissionsData, isLoading } = usePermissionList({
search,
page: pagination.pageIndex + 1,
per_page: pagination.pageSize,
})
const deletePermission = useDeletePermission()
});
const deletePermission = useDeletePermission();
const permissions: TPermissionItem[] = permissionsData?.data ?? []
const totalItems = permissionsData?.meta?.total ?? permissions.length
const permissions: TPermissionItem[] = permissionsData?.data ?? [];
const totalItems = permissionsData?.meta?.total ?? permissions.length;
const handleDelete = async (id: string) => {
try {
await deletePermission.mutateAsync(id)
toast.success('Data permissions berhasil dihapus')
setDeleteId(null)
await deletePermission.mutateAsync(id);
toast.success('Data permission berhasil dihapus');
setDeleteId(null);
} catch (error) {
console.log(error)
toast.error('Data permissions gagal dihapus')
console.log(error);
toast.error('Data permission gagal dihapus');
}
}
};
const columns: ColumnDef<TPermissionItem>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'No',
accessorKey: 'id',
},
{
header: 'Name',
accessorKey: 'name',
header: ({ table }) => <SelectAllCheckbox table={table} />,
cell: ({ row }) => <RowSelectCheckbox row={row} />,
},
{ header: 'No', accessorKey: 'id' },
{ header: 'Name', accessorKey: 'name' },
{
header: 'Action',
cell: ({ row }) => (
<div className="flex gap-[8px]">
<div className="flex items-center gap-2">
<Button
variant="primary"
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation()
navigate({ to: '/permissions/$id', params: { id: row.original.id } })
e.stopPropagation();
navigate({
to: '/permissions/$id',
params: { id: row.original.id },
});
}}
className="flex items-center gap-2"
>
<EditOutlined /> Update
<Pencil className="size-3.5" />
Update
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
setDeleteId(row.original.id);
}}
>
<Trash2 className="size-3.5" />
Delete
</Button>
{deleteId === row.original.id ? (
<div className="flex items-center gap-2">
<span className="text-label2 text-neutral-500">Yakin?</span>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleDelete(row.original.id)
}}
>
Ya
</Button>
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation()
setDeleteId(null)
}}
>
Batal
</Button>
</div>
) : (
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation()
setDeleteId(row.original.id)
}}
className="flex items-center gap-2"
>
<DeleteOutlined /> Delete
</Button>
)}
</div>
),
},
]
];
const table = useReactTable({
data: permissions,
columns,
state: {
pagination,
rowSelection,
},
state: { pagination, rowSelection },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
@@ -160,53 +121,61 @@ function PermissionsPage() {
onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true,
})
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Permissions</h1>
</header>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<BackofficeWrapper
title="Permissions"
description="Kelola hak akses sistem"
>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Cari berdasarkan nama permissions"
className="pl-12 w-full max-h-full"
placeholder="Cari nama permission…"
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex gap-3 text-nowrap"
onClick={() => navigate({ to: '/permissions/create' })}
>
<PlusOutlined />
Tambah Permissions
</Button>
</div>
<Button
onClick={() => navigate({ to: '/permissions/create' })}
size="md"
>
<Plus className="size-4" />
Tambah Permission
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-neutral-400">Loading...</div>
<div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : (
<DataTable
data={permissions}
columns={columns}
pageSize={9}
table={table}
manualPagination
pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</section>
</main>
</Fragment>
)
</CardContent>
</Card>
<DeleteConfirmDialog
open={!!deleteId}
onOpenChange={(o) => !o && setDeleteId(null)}
onConfirm={() => deleteId && handleDelete(deleteId)}
title="Hapus permission ini?"
/>
</BackofficeWrapper>
);
}
@@ -1,13 +1,22 @@
import { createFileRoute } from '@tanstack/react-router'
import * as React from 'react'
import { FC, Fragment, ReactElement, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router';
import * as React from 'react';
import { Filter as FilterIcon, Search, ClipboardCheck } from 'lucide-react';
import {
FilterOutlined,
SearchOutlined,
AuditOutlined,
} from '@ant-design/icons'
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms'
Badge,
Button,
Card,
CardContent,
CardHeader,
Input,
Popover,
PopoverContent,
PopoverTrigger,
} from '@imphnen-frontend-service/ui/atoms';
import {
DataTable,
Filter,
BackofficeWrapper,
} from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
@@ -15,19 +24,23 @@ import {
PaginationState,
useReactTable,
RowSelectionState,
} from '@tanstack/react-table'
import ModalProcessDelivery from './_components/prizes/modal-process-item'
} from '@tanstack/react-table';
import ModalProcessDelivery from './_components/prizes/modal-process-item';
import {
SelectAllCheckbox,
RowSelectCheckbox,
} from '../../components/list-helpers';
type OrderValid = 'valid' | 'invalid' | 'unchecked'
type Status = 'undelivered' | 'delivered'
type OrderValid = 'valid' | 'invalid' | 'unchecked';
type Status = 'undelivered' | 'delivered';
interface Prize {
id: number
name: string
orderValid: OrderValid
items: string
address: string
status: Status
id: number;
name: string;
orderValid: OrderValid;
items: string;
address: string;
status: Status;
}
const items = [
@@ -37,7 +50,7 @@ const items = [
'Sticker Isi 3',
'Sticker Isi 5',
'Gelang Karet',
]
];
const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
@@ -45,141 +58,108 @@ const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
orderValid: (i % 3 === 0
? 'invalid'
: i % 5 === 0
? 'unchecked'
: 'valid') as OrderValid,
? 'unchecked'
: 'valid') as OrderValid,
items: items[i % items.length],
address: 'Jl. Pantai Cibaduyut Indah',
status: (i % 3 === 0 ? 'undelivered' : 'delivered') as Status,
}))
}));
export const Route = createFileRoute('/_authenticated/prizes')({
component: PrizesPage,
})
});
function PrizesPage() {
const [showModalProcessDelivery, setShowModalProcessDelivery] =
useState(false)
React.useState(false);
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
})
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
const [showFilter, setShowFilter] = useState(false)
pageSize: 10,
});
const [rowSelection, setRowSelection] =
React.useState<RowSelectionState>({});
const [showFilter, setShowFilter] = React.useState(false);
const deliveryOptions = [
{ id: 'option1', value: 'undelivered', label: 'Undelivered' },
{ id: 'option1', value: 'delivered', label: 'Delivered' },
]
{ id: 'undelivered', value: 'undelivered', label: 'Undelivered' },
{ id: 'delivered', value: 'delivered', label: 'Delivered' },
];
const orderValidVariants: Record<
OrderValid,
'success' | 'destructive' | 'warning'
> = {
valid: 'success',
invalid: 'destructive',
unchecked: 'warning',
};
const orderValidText: Record<OrderValid, string> = {
valid: 'Valid',
invalid: 'Invalid',
unchecked: 'Unchecked',
};
const statusVariants: Record<Status, 'success' | 'destructive'> = {
delivered: 'success',
undelivered: 'destructive',
};
const statusText: Record<Status, string> = {
delivered: 'Delivered',
undelivered: 'Undelivered',
};
const columns: ColumnDef<Prize>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'No',
accessorKey: 'id',
},
{
header: 'Nama Lengkap',
accessorKey: 'name',
header: ({ table }) => <SelectAllCheckbox table={table} />,
cell: ({ row }) => <RowSelectCheckbox row={row} />,
},
{ header: 'No', accessorKey: 'id' },
{ header: 'Nama Lengkap', accessorKey: 'name' },
{
header: 'Order Valid?',
accessorKey: 'orderValid',
cell: ({ row }) => {
const status = row.original.orderValid
const statusColors: Record<OrderValid, string> = {
valid: 'bg-success-200 text-success-500',
invalid: 'bg-danger-200 text-danger-500',
unchecked: 'bg-warning-200 text-warning-900',
}
const statusText: Record<OrderValid, string> = {
valid: 'Valid',
invalid: 'Invalid',
unchecked: 'Unchecked',
}
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
)
},
},
{
header: 'Items',
accessorKey: 'items',
},
{
header: 'Alamat Pengiriman',
accessorKey: 'address',
cell: ({ row }) => (
<Badge variant={orderValidVariants[row.original.orderValid]}>
{orderValidText[row.original.orderValid]}
</Badge>
),
},
{ header: 'Items', accessorKey: 'items' },
{ header: 'Alamat Pengiriman', accessorKey: 'address' },
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status
const statusColors: Record<Status, string> = {
delivered: 'bg-success-200 text-success-500',
undelivered: 'bg-danger-200 text-danger-500',
}
const statusText: Record<Status, string> = {
delivered: 'Delivered',
undelivered: 'Undelivered',
}
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
)
},
cell: ({ row }) => (
<Badge variant={statusVariants[row.original.status]}>
{statusText[row.original.status]}
</Badge>
),
},
{
header: 'Action',
cell: ({ row }) => (
cell: () => (
<Button
variant="primary"
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation()
setShowModalProcessDelivery(true)
e.stopPropagation();
setShowModalProcessDelivery(true);
}}
className="flex items-center gap-2 w-full"
>
<AuditOutlined className="text-[16px]" /> Process
<ClipboardCheck className="size-3.5" />
Process
</Button>
),
},
]
];
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
state: { pagination, rowSelection },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
@@ -187,59 +167,55 @@ function PrizesPage() {
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
})
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Data Pengiriman Hadiah</h1>
</header>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<BackofficeWrapper
title="Data Pengiriman Hadiah"
description="Proses pengiriman hadiah ke pemenang"
>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Cari berdasarkan nama lengkap, nomor order Shopee"
className="pl-12 w-full max-h-full"
className="pl-9"
placeholder="Cari nama atau nomor order…"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex items-center gap-3"
onClick={() => setShowFilter(!showFilter)}
>
<FilterOutlined />
Filters
</Button>
{showFilter && (
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
<Filter
options={deliveryOptions}
onClose={() => setShowFilter(false)}
onFilterChange={(value) => {
console.log('Selected filter:', value)
}}
/>
</div>
)}
</div>
<Popover open={showFilter} onOpenChange={setShowFilter}>
<PopoverTrigger asChild>
<Button variant="secondary" size="md">
<FilterIcon className="size-4" />
Filters
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-auto p-0">
<Filter
options={deliveryOptions}
title="Status"
onClose={() => setShowFilter(false)}
onFilterChange={(value) => {
console.log('Selected filter:', value);
}}
/>
</PopoverContent>
</Popover>
</div>
</CardHeader>
<CardContent>
<DataTable data={mockData} columns={columns} table={table} />
</section>
</main>
</CardContent>
</Card>
<ModalProcessDelivery
isOpen={showModalProcessDelivery}
onClose={() => setShowModalProcessDelivery(false)}
handleProcessDelivery={() => {
console.log('Action ketika user menekan tombol Proses Pengiriman')
console.log('Action proses pengiriman');
}}
/>
</Fragment>
)
</BackofficeWrapper>
);
}
@@ -1,182 +1,162 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons'
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms'
import { BackofficeWrapper, DataTable } from '@imphnen-frontend-service/ui/organisms'
import { cn } from '@imphnen-frontend-service/utils'
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from '@tanstack/react-table'
import { useState } from 'react'
import { toast } from 'sonner'
import { useRoadmapList, useDeleteRoadmap, TRoadmapListItem, TRoadmapStatus } from '@imphnen-frontend-service/service'
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import { Search, Plus, Pencil, Trash2 } from 'lucide-react';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@imphnen-frontend-service/ui/atoms';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { cn } from '@imphnen-frontend-service/utils';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import { toast } from 'sonner';
import {
useRoadmapList,
useDeleteRoadmap,
TRoadmapListItem,
TRoadmapStatus,
} from '@imphnen-frontend-service/service';
import {
SelectAllCheckbox,
RowSelectCheckbox,
DeleteConfirmDialog,
} from '../../components/list-helpers';
export const Route = createFileRoute('/_authenticated/roadmap-dimentorin')({
component: RoadmapDimentorinPage,
})
});
function RoadmapDimentorinPage(): React.ReactElement {
const navigate = useNavigate()
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [deletingId, setDeletingId] = useState<string | null>(null)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
function RoadmapDimentorinPage() {
const navigate = useNavigate();
const [search, setSearch] = React.useState('');
const [statusFilter, setStatusFilter] = React.useState('all');
const [deletingId, setDeletingId] = React.useState<string | null>(null);
const [rowSelection, setRowSelection] =
React.useState<RowSelectionState>({});
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
})
pageSize: 10,
});
const { data: roadmapData, isLoading } = useRoadmapList()
const deleteRoadmap = useDeleteRoadmap()
const { data: roadmapData, isLoading } = useRoadmapList();
const deleteRoadmap = useDeleteRoadmap();
const allItems: TRoadmapListItem[] = roadmapData ?? []
const allItems: TRoadmapListItem[] = roadmapData ?? [];
const filteredItems = allItems.filter((item) => {
const matchSearch = !search || item.title.toLowerCase().includes(search.toLowerCase())
const matchStatus = !statusFilter || item.status === statusFilter
return matchSearch && matchStatus
})
const matchSearch =
!search || item.title.toLowerCase().includes(search.toLowerCase());
const matchStatus = statusFilter === 'all' || item.status === statusFilter;
return matchSearch && matchStatus;
});
const handleDelete = async (id: string) => {
try {
await deleteRoadmap.mutateAsync(id)
toast.success('Roadmap berhasil dihapus')
setDeletingId(null)
await deleteRoadmap.mutateAsync(id);
toast.success('Roadmap berhasil dihapus');
setDeletingId(null);
} catch (error) {
console.log(error)
toast.error('Gagal menghapus roadmap')
console.log(error);
toast.error('Gagal menghapus roadmap');
}
}
};
const statusColors: Record<TRoadmapStatus, string> = {
upcoming: 'bg-warning-200 text-warning-700',
in_progress: 'bg-primary-200 text-primary-700',
completed: 'bg-success-200 text-success-500',
}
const statusVariants: Record<
TRoadmapStatus,
'warning' | 'info' | 'success'
> = {
upcoming: 'warning',
in_progress: 'info',
completed: 'success',
};
const statusText: Record<TRoadmapStatus, string> = {
upcoming: 'Upcoming',
in_progress: 'In Progress',
completed: 'Completed',
}
};
const columns: ColumnDef<TRoadmapListItem>[] = [
{
id: 'select',
meta: { cellClassName: cn('w-20') },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'title',
header: 'Title',
accessorKey: 'title',
meta: { cellClassName: cn('w-10') },
header: ({ table }) => <SelectAllCheckbox table={table} />,
cell: ({ row }) => <RowSelectCheckbox row={row} />,
},
{ id: 'title', header: 'Title', accessorKey: 'title' },
{
id: 'description',
header: 'Description',
accessorKey: 'description',
cell: ({ row }) => (
<span className="line-clamp-2">{row.original.description}</span>
<span className="line-clamp-2 max-w-md">{row.original.description}</span>
),
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status
return (
<div className={`py-2 px-4 rounded-md text-center ${statusColors[status] ?? 'bg-neutral-200 text-neutral-700'}`}>
{statusText[status] ?? status}
</div>
)
},
},
{
id: 'votes',
header: 'Votes',
accessorKey: 'votes',
cell: ({ row }) => (
<Badge variant={statusVariants[row.original.status] ?? 'secondary'}>
{statusText[row.original.status] ?? row.original.status}
</Badge>
),
},
{ id: 'votes', header: 'Votes', accessorKey: 'votes' },
{
header: 'Action',
meta: { cellClassName: cn('w-72') },
cell: ({ row }) => (
<div className="flex gap-[8px]">
{deletingId === row.original.id ? (
<>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleDelete(row.original.id)
}}
className="flex items-center gap-2"
>
Konfirmasi
</Button>
<Button
variant="bordered"
size="sm"
onClick={(e) => {
e.stopPropagation()
setDeletingId(null)
}}
className="flex items-center gap-2"
>
Batal
</Button>
</>
) : (
<>
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation()
navigate({ to: '/roadmap-dimentorin/$id', params: { id: row.original.id } })
}}
className="flex items-center gap-2"
>
<EditOutlined /> Edit
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation()
setDeletingId(row.original.id)
}}
className="flex items-center gap-2"
>
<DeleteOutlined /> Delete
</Button>
</>
)}
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
navigate({
to: '/roadmap-dimentorin/$id',
params: { id: row.original.id },
});
}}
>
<Pencil className="size-3.5" />
Edit
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
setDeletingId(row.original.id);
}}
>
<Trash2 className="size-3.5" />
</Button>
</div>
),
},
]
];
const table = useReactTable({
data: filteredItems,
columns,
state: {
pagination,
rowSelection,
},
state: { pagination, rowSelection },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
@@ -184,51 +164,64 @@ function RoadmapDimentorinPage(): React.ReactElement {
onPaginationChange: setPagination,
pageCount: Math.ceil(filteredItems.length / pagination.pageSize),
manualPagination: false,
})
});
return (
<BackofficeWrapper title="Dimentorin.dev">
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Content & Roadmap</h1>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex items-center justify-between mb-9">
<h2 className="text-p2 font-semibold text-neutral-600">AI Roadmaps</h2>
<Button
type="button"
variant="primary"
className="flex items-center gap-2"
onClick={() => navigate({ to: '/roadmap-dimentorin/create' })}
>
<PlusOutlined /> Buat Roadmap
</Button>
</div>
<div className="flex justify-between items-center gap-5 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan judul roadmap"
className="pl-12 w-full max-h-full"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
<BackofficeWrapper
title="Content & Roadmap"
description="Kelola AI roadmap dimentorin"
>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 flex-col gap-2 sm:flex-row">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Cari judul roadmap…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="upcoming">Upcoming</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
</SelectContent>
</Select>
</div>
<Button
size="md"
onClick={() => navigate({ to: '/roadmap-dimentorin/create' })}
>
<Plus className="size-4" />
Buat Roadmap
</Button>
</div>
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="">Semua Status</option>
<option value="upcoming">Upcoming</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
</Select>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : (
<DataTable data={filteredItems} columns={columns} table={table} />
)}
</CardContent>
</Card>
{isLoading ? (
<div className="text-center py-8 text-neutral-400">Loading...</div>
) : (
<DataTable data={filteredItems} columns={columns} table={table} />
)}
</section>
<DeleteConfirmDialog
open={!!deletingId}
onOpenChange={(o) => !o && setDeletingId(null)}
onConfirm={() => deletingId && handleDelete(deletingId)}
title="Hapus roadmap ini?"
/>
</BackofficeWrapper>
)
);
}
@@ -1,13 +1,17 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { Fragment, useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
import {
SearchOutlined,
EditOutlined,
DeleteOutlined,
PlusOutlined,
} from '@ant-design/icons'
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
Button,
Card,
CardContent,
CardHeader,
Input,
} from '@imphnen-frontend-service/ui/atoms';
import {
DataTable,
BackofficeWrapper,
} from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
@@ -15,144 +19,98 @@ import {
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table'
} from '@tanstack/react-table';
import {
useRoleList,
useDeleteRole,
TRolesListItem,
} from '@imphnen-frontend-service/service'
import React from 'react'
import { toast } from 'sonner'
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
import {
SelectAllCheckbox,
RowSelectCheckbox,
DeleteConfirmDialog,
} from '../../components/list-helpers';
export const Route = createFileRoute('/_authenticated/roles')({
component: RolesPage,
})
});
function RolesPage() {
const navigate = useNavigate()
const [search, setSearch] = useState('')
const [deleteId, setDeleteId] = useState<string | null>(null)
const navigate = useNavigate();
const [search, setSearch] = React.useState('');
const [deleteId, setDeleteId] = React.useState<string | null>(null);
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
})
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
pageSize: 10,
});
const [rowSelection, setRowSelection] =
React.useState<RowSelectionState>({});
const { data: rolesData, isLoading } = useRoleList({
search,
page: pagination.pageIndex + 1,
per_page: pagination.pageSize,
})
const deleteRole = useDeleteRole()
});
const deleteRole = useDeleteRole();
const roles: TRolesListItem[] = rolesData?.data ?? []
const totalItems = rolesData?.meta?.total ?? roles.length
const roles: TRolesListItem[] = rolesData?.data ?? [];
const totalItems = rolesData?.meta?.total ?? roles.length;
const handleDelete = async (id: string) => {
try {
await deleteRole.mutateAsync(id)
toast.success('Data role berhasil dihapus')
setDeleteId(null)
await deleteRole.mutateAsync(id);
toast.success('Data role berhasil dihapus');
setDeleteId(null);
} catch (error) {
console.log(error)
toast.error('Data role gagal dihapus')
console.log(error);
toast.error('Data role gagal dihapus');
}
}
};
const columns: ColumnDef<TRolesListItem>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'ID',
accessorKey: 'id',
},
{
header: 'Roles Name',
accessorKey: 'name',
header: ({ table }) => <SelectAllCheckbox table={table} />,
cell: ({ row }) => <RowSelectCheckbox row={row} />,
},
{ header: 'ID', accessorKey: 'id' },
{ header: 'Roles Name', accessorKey: 'name' },
{
header: 'Action',
cell: ({ row }) => (
<div className="flex gap-[8px]">
<div className="flex items-center gap-2">
<Button
variant="primary"
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation()
navigate({ to: '/roles/$id', params: { id: row.original.id } })
e.stopPropagation();
navigate({ to: '/roles/$id', params: { id: row.original.id } });
}}
className="flex items-center gap-2"
>
<EditOutlined /> Update
<Pencil className="size-3.5" />
Update
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
setDeleteId(row.original.id);
}}
>
<Trash2 className="size-3.5" />
Delete
</Button>
{deleteId === row.original.id ? (
<div className="flex items-center gap-2">
<span className="text-label2 text-neutral-500">Yakin?</span>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleDelete(row.original.id)
}}
>
Ya
</Button>
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation()
setDeleteId(null)
}}
>
Batal
</Button>
</div>
) : (
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation()
setDeleteId(row.original.id)
}}
className="flex items-center gap-2"
>
<DeleteOutlined /> Delete
</Button>
)}
</div>
),
},
]
];
const table = useReactTable({
data: roles,
columns,
state: {
pagination,
rowSelection,
},
state: { pagination, rowSelection },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
@@ -160,53 +118,58 @@ function RolesPage() {
onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true,
})
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Roles</h1>
</header>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<BackofficeWrapper title="Roles" description="Kelola role dan akses">
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Cari berdasarkan nama roles"
className="pl-12 w-full max-h-full"
placeholder="Cari nama role…"
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex gap-3 text-nowrap"
onClick={() => navigate({ to: '/roles/create' })}
>
<PlusOutlined />
Tambah Role
</Button>
</div>
<Button
onClick={() => navigate({ to: '/roles/create' })}
size="md"
>
<Plus className="size-4" />
Tambah Role
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-neutral-400">Loading...</div>
<div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : (
<DataTable
data={roles}
columns={columns}
pageSize={9}
table={table}
manualPagination
pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</section>
</main>
</Fragment>
)
</CardContent>
</Card>
<DeleteConfirmDialog
open={!!deleteId}
onOpenChange={(o) => !o && setDeleteId(null)}
onConfirm={() => deleteId && handleDelete(deleteId)}
title="Hapus role ini?"
/>
</BackofficeWrapper>
);
}
@@ -1,64 +1,82 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { SearchOutlined } from '@ant-design/icons'
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms'
import { BackofficeWrapper, DataTable } from '@imphnen-frontend-service/ui/organisms'
import { cn } from '@imphnen-frontend-service/utils'
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from '@tanstack/react-table'
import { ReactElement, useState } from 'react'
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service'
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import { Search, Eye } from 'lucide-react';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@imphnen-frontend-service/ui/atoms';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { cn } from '@imphnen-frontend-service/utils';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import {
useMySessions,
TSessionListItem,
} from '@imphnen-frontend-service/service';
import {
SelectAllCheckbox,
RowSelectCheckbox,
} from '../../components/list-helpers';
export const Route = createFileRoute('/_authenticated/session-dimentorin')({
component: SessionDimentorinPage,
})
});
function SessionDimentorinPage(): ReactElement {
const navigate = useNavigate()
const [statusFilter, setStatusFilter] = useState('')
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
function SessionDimentorinPage() {
const navigate = useNavigate();
const [statusFilter, setStatusFilter] = React.useState('all');
const [rowSelection, setRowSelection] =
React.useState<RowSelectionState>({});
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
})
pageSize: 10,
});
const { data: sessionsData, isLoading } = useMySessions(
statusFilter ? { status: statusFilter } : undefined
)
statusFilter !== 'all' ? { status: statusFilter } : undefined
);
const sessions: TSessionListItem[] = sessionsData?.sessions ?? []
const totalItems = sessionsData?.total ?? sessions.length
const sessions: TSessionListItem[] = sessionsData?.sessions ?? [];
const totalItems = sessionsData?.total ?? sessions.length;
const statusVariants: Record<
string,
'warning' | 'info' | 'success' | 'destructive' | 'secondary'
> = {
pending: 'warning',
confirmed: 'info',
ongoing: 'warning',
completed: 'success',
cancelled: 'destructive',
};
const columns: ColumnDef<TSessionListItem>[] = [
{
id: 'select',
meta: { cellClassName: cn('w-20') },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'id',
header: 'ID Sesi',
accessorKey: 'id',
},
{
id: 'mentorId',
header: 'Nama Mentor',
accessorKey: 'mentor_id',
meta: { cellClassName: cn('w-10') },
header: ({ table }) => <SelectAllCheckbox table={table} />,
cell: ({ row }) => <RowSelectCheckbox row={row} />,
},
{ id: 'id', header: 'ID Sesi', accessorKey: 'id' },
{ id: 'mentorId', header: 'Nama Mentor', accessorKey: 'mentor_id' },
{
id: 'menteeName',
header: 'Nama Mentee',
@@ -69,55 +87,49 @@ function SessionDimentorinPage(): ReactElement {
header: 'Waktu',
accessorKey: 'scheduled_at',
cell: ({ row }) => (
<span>{new Date(row.original.scheduled_at).toLocaleString('id-ID')}</span>
<span>
{new Date(row.original.scheduled_at).toLocaleString('id-ID')}
</span>
),
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status
const statusColors: Record<string, string> = {
pending: 'bg-warning-200 text-warning-700',
confirmed: 'bg-primary-200 text-primary-700',
ongoing: 'bg-warning-200 text-warning-700',
completed: 'bg-success-200 text-success-500',
cancelled: 'bg-danger-200 text-danger-500',
}
return (
<div className={`py-2 px-4 rounded-md text-center capitalize ${statusColors[status] ?? 'bg-neutral-200 text-neutral-700'}`}>
{status}
</div>
)
},
cell: ({ row }) => (
<Badge
variant={statusVariants[row.original.status] ?? 'secondary'}
className="capitalize"
>
{row.original.status}
</Badge>
),
},
{
header: 'Action',
meta: { cellClassName: cn('w-52') },
cell: ({ row }) => (
<Button
variant="primary"
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation()
navigate({ to: '/session-dimentorin/$id', params: { id: row.original.id } })
e.stopPropagation();
navigate({
to: '/session-dimentorin/$id',
params: { id: row.original.id },
});
}}
className="flex items-center gap-2 w-max"
>
<SearchOutlined className="text-[16px]" /> Cek Detail
<Eye className="size-3.5" />
Detail
</Button>
),
},
]
];
const table = useReactTable({
data: sessions,
columns,
state: {
pagination,
rowSelection,
},
state: { pagination, rowSelection },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
@@ -125,39 +137,55 @@ function SessionDimentorinPage(): ReactElement {
onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true,
})
});
return (
<BackofficeWrapper title="Dimentorin.dev">
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Session Management</h1>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-5 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama lengkap"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
<BackofficeWrapper
title="Session Management"
description="Kelola sesi mentoring aktif"
>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input className="pl-9" placeholder="Cari nama lengkap…" />
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="confirmed">Confirmed</SelectItem>
<SelectItem value="ongoing">On Going</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="">Semua Status</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="ongoing">On Going</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</Select>
</div>
{isLoading ? (
<div className="text-center py-8 text-neutral-400">Loading...</div>
) : (
<DataTable data={sessions} columns={columns} table={table} />
)}
</section>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : (
<DataTable
data={sessions}
columns={columns}
table={table}
manualPagination
pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</CardContent>
</Card>
</BackofficeWrapper>
)
);
}
@@ -1,6 +1,6 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { Input, Select, Textarea } from '@imphnen-frontend-service/ui/atoms'
import { Input, NativeSelect as Select, Textarea } from '@imphnen-frontend-service/ui/atoms'
import { cn, For } from '@imphnen-frontend-service/utils'
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service'
@@ -1,58 +1,74 @@
import { createFileRoute } from '@tanstack/react-router'
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms'
import { cn, For } from '@imphnen-frontend-service/utils'
import { useState } from 'react'
import { GeneralSettings } from './_components/settings-dimentorin/general'
import { UserRolesPermission } from './_components/settings-dimentorin/user-roles-permission'
import { NotificationSettings } from './_components/settings-dimentorin/notification'
import { SecuritySettings } from './_components/settings-dimentorin/security'
import { PaymentSettings } from './_components/settings-dimentorin/payment'
const TABS = {
general: 'General Settings',
userRolePermissions: 'User Roles & Permissions',
notification: 'Notification Settings',
security: 'Security',
payment: 'Payment',
} as const
type Tabs = typeof TABS[keyof typeof TABS]
import { createFileRoute } from '@tanstack/react-router';
import * as React from 'react';
import {
Card,
CardContent,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@imphnen-frontend-service/ui/atoms';
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import { GeneralSettings } from './_components/settings-dimentorin/general';
import { UserRolesPermission } from './_components/settings-dimentorin/user-roles-permission';
import { NotificationSettings } from './_components/settings-dimentorin/notification';
import { SecuritySettings } from './_components/settings-dimentorin/security';
import { PaymentSettings } from './_components/settings-dimentorin/payment';
export const Route = createFileRoute('/_authenticated/settings-dimentorin')({
component: SettingsDimentorinPage,
})
});
function SettingsDimentorinPage(): React.ReactElement {
const [activeTab, setActiveTab] = useState<Tabs>(TABS.general)
return (
<BackofficeWrapper title="Dimentorin.dev">
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Settings</h1>
<div className="flex items-start gap-x-8">
<div className="w-64 bg-white p-2.5 shadow space-y-2 rounded-md">
<For data={Object.values(TABS)}>
{(tab) => (
<button
key={tab}
className={cn(
'px-4 py-3 w-full text-left font-medium rounded-md text-neutral-400 cursor-pointer select-none hover:bg-primary-100',
activeTab === tab && 'bg-primary-500 text-white hover:bg-primary-600'
)}
onClick={() => setActiveTab(tab)}
>
{tab}
</button>
)}
</For>
</div>
<div className="bg-white px-8 py-6 shadow space-y-2 rounded-md flex-1">
{activeTab === TABS.general && <GeneralSettings />}
{activeTab === TABS.userRolePermissions && <UserRolesPermission />}
{activeTab === TABS.notification && <NotificationSettings />}
{activeTab === TABS.security && <SecuritySettings />}
{activeTab === TABS.payment && <PaymentSettings />}
</div>
</div>
<BackofficeWrapper
title="Dimentorin Settings"
description="Konfigurasi platform Dimentorin.dev"
>
<Tabs defaultValue="general" className="gap-6">
<TabsList className="flex-wrap">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="roles">Roles & Permissions</TabsTrigger>
<TabsTrigger value="notification">Notification</TabsTrigger>
<TabsTrigger value="security">Security</TabsTrigger>
<TabsTrigger value="payment">Payment</TabsTrigger>
</TabsList>
<TabsContent value="general">
<Card>
<CardContent className="pt-6">
<GeneralSettings />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="roles">
<Card>
<CardContent className="pt-6">
<UserRolesPermission />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="notification">
<Card>
<CardContent className="pt-6">
<NotificationSettings />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="security">
<Card>
<CardContent className="pt-6">
<SecuritySettings />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="payment">
<Card>
<CardContent className="pt-6">
<PaymentSettings />
</CardContent>
</Card>
</TabsContent>
</Tabs>
</BackofficeWrapper>
)
);
}
@@ -1,13 +1,22 @@
import { createFileRoute } from '@tanstack/react-router'
import * as React from 'react'
import { FC, Fragment, ReactElement, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router';
import * as React from 'react';
import { Filter as FilterIcon, Search, ClipboardCheck } from 'lucide-react';
import {
FilterOutlined,
SearchOutlined,
AuditOutlined,
} from '@ant-design/icons'
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms'
Badge,
Button,
Card,
CardContent,
CardHeader,
Input,
Popover,
PopoverContent,
PopoverTrigger,
} from '@imphnen-frontend-service/ui/atoms';
import {
DataTable,
Filter,
BackofficeWrapper,
} from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
@@ -15,131 +24,111 @@ import {
PaginationState,
useReactTable,
RowSelectionState,
} from '@tanstack/react-table'
import ModalValidate from './_components/transactions/modal-validate'
} from '@tanstack/react-table';
import ModalValidate from './_components/transactions/modal-validate';
import {
SelectAllCheckbox,
RowSelectCheckbox,
} from '../../components/list-helpers';
type TransactionStatus = 'valid' | 'invalid' | 'unchecked'
type TransactionStatus = 'valid' | 'invalid' | 'unchecked';
interface Transaction {
id: number
name: string
transactionNumber: string
status: TransactionStatus
id: number;
name: string;
transactionNumber: string;
status: TransactionStatus;
}
const mockTransactions: Transaction[] = Array.from({ length: 20 }, (_, i) => ({
id: i + 1,
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
transactionNumber: '25D2133Y9AFYBD',
status: (i % 3 === 0
? 'invalid'
: i % 5 === 0
? 'unchecked'
: 'valid') as TransactionStatus,
}))
const mockTransactions: Transaction[] = Array.from(
{ length: 20 },
(_, i) => ({
id: i + 1,
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
transactionNumber: '25D2133Y9AFYBD',
status: (i % 3 === 0
? 'invalid'
: i % 5 === 0
? 'unchecked'
: 'valid') as TransactionStatus,
})
);
export const Route = createFileRoute('/_authenticated/transactions')({
component: TransactionsPage,
})
});
function TransactionsPage() {
const [showModalValidate, setShowModalValidate] = useState(false)
const [showModalValidate, setShowModalValidate] = React.useState(false);
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
})
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
const [showFilter, setShowFilter] = useState(false)
pageSize: 10,
});
const [rowSelection, setRowSelection] =
React.useState<RowSelectionState>({});
const [showFilter, setShowFilter] = React.useState(false);
const validationOptions = [
{ id: 'option1', value: 'unchecked', label: 'Unchecked' },
{ id: 'option2', value: 'valid', label: 'Valid' },
{ id: 'option3', value: 'invalid', label: 'Invalid' },
]
{ id: 'unchecked', value: 'unchecked', label: 'Unchecked' },
{ id: 'valid', value: 'valid', label: 'Valid' },
{ id: 'invalid', value: 'invalid', label: 'Invalid' },
];
const statusVariants: Record<
TransactionStatus,
'success' | 'destructive' | 'warning'
> = {
valid: 'success',
invalid: 'destructive',
unchecked: 'warning',
};
const statusText: Record<TransactionStatus, string> = {
valid: 'Valid',
invalid: 'Invalid',
unchecked: 'Unchecked',
};
const columns: ColumnDef<Transaction>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'No',
accessorKey: 'id',
},
{
header: 'Nama Lengkap',
accessorKey: 'name',
},
{
header: 'Nomor Transaksi',
accessorKey: 'transactionNumber',
header: ({ table }) => <SelectAllCheckbox table={table} />,
cell: ({ row }) => <RowSelectCheckbox row={row} />,
},
{ header: 'No', accessorKey: 'id' },
{ header: 'Nama Lengkap', accessorKey: 'name' },
{ header: 'Nomor Transaksi', accessorKey: 'transactionNumber' },
{
header: 'Order Valid?',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status
const statusColors: Record<TransactionStatus, string> = {
valid: 'bg-success-200 text-success-500',
invalid: 'bg-danger-200 text-danger-500',
unchecked: 'bg-warning-200 text-warning-900',
}
const statusText: Record<TransactionStatus, string> = {
valid: 'Valid',
invalid: 'Invalid',
unchecked: 'Unchecked',
}
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
)
},
cell: ({ row }) => (
<Badge variant={statusVariants[row.original.status]}>
{statusText[row.original.status]}
</Badge>
),
},
{
header: 'Action',
cell: ({ row }) => (
cell: () => (
<Button
variant="primary"
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation()
setShowModalValidate(true)
e.stopPropagation();
setShowModalValidate(true);
}}
className="flex items-center gap-2 w-full"
>
<AuditOutlined className="text-[16px]" /> Update
<ClipboardCheck className="size-3.5" />
Update
</Button>
),
},
]
];
const table = useReactTable({
data: mockTransactions,
columns,
state: {
pagination,
rowSelection,
},
state: { pagination, rowSelection },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
@@ -147,64 +136,58 @@ function TransactionsPage() {
onPaginationChange: setPagination,
pageCount: Math.ceil(mockTransactions.length / pagination.pageSize),
manualPagination: false,
})
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">Validasi Transaksi</h1>
</header>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<BackofficeWrapper
title="Validasi Transaksi"
description="Verifikasi status transaksi pengguna"
>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Cari berdasarkan nama lengkap, nomor order Shopee"
className="pl-12 w-full max-h-full"
className="pl-9"
placeholder="Cari nama atau nomor order…"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex items-center gap-3"
onClick={() => setShowFilter(!showFilter)}
>
<FilterOutlined />
Filters
</Button>
{showFilter && (
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
<Filter
options={validationOptions}
onClose={() => setShowFilter(false)}
onFilterChange={(value) => {
console.log('Selected filter:', value)
}}
/>
</div>
)}
</div>
<Popover open={showFilter} onOpenChange={setShowFilter}>
<PopoverTrigger asChild>
<Button variant="secondary" size="md">
<FilterIcon className="size-4" />
Filters
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-auto p-0">
<Filter
options={validationOptions}
title="Status"
onClose={() => setShowFilter(false)}
onFilterChange={(value) => {
console.log('Selected filter:', value);
}}
/>
</PopoverContent>
</Popover>
</div>
</CardHeader>
<CardContent>
<DataTable data={mockTransactions} columns={columns} table={table} />
</section>
</main>
</CardContent>
</Card>
<ModalValidate
isOpen={showModalValidate}
onClose={() => setShowModalValidate(false)}
handleValid={() => {
console.log('Action ketika user klik Valid')
console.log('Action ketika user klik Valid');
}}
handleInvalid={() => {
console.log('Action ketika user klik Tidak Valid')
console.log('Action ketika user klik Tidak Valid');
}}
/>
</Fragment>
)
</BackofficeWrapper>
);
}
@@ -1,11 +1,23 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { DeleteOutlined, SearchOutlined } from '@ant-design/icons'
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import { Eye, Search, Trash2 } from 'lucide-react';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
Input,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@imphnen-frontend-service/ui/atoms';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms'
import { cn, For } from '@imphnen-frontend-service/utils'
} from '@imphnen-frontend-service/ui/organisms';
import { cn } from '@imphnen-frontend-service/utils';
import {
ColumnDef,
getCoreRowModel,
@@ -13,98 +25,85 @@ import {
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table'
import { ReactElement, useState } from 'react'
import { toast } from 'sonner'
} from '@tanstack/react-table';
import { toast } from 'sonner';
import {
useMentorList,
useUserList,
useDeleteMentor,
MentorDetailResponseDto,
TUsersListItem,
} from '@imphnen-frontend-service/service'
} from '@imphnen-frontend-service/service';
import {
SelectAllCheckbox,
RowSelectCheckbox,
DeleteConfirmDialog,
} from '../../components/list-helpers';
export const Route = createFileRoute('/_authenticated/users-dimentorin')({
component: UsersDimentorinPage,
})
});
function UsersDimentorinPage(): ReactElement {
const TABS = ['mentor', 'mentee'] as const
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor')
const [search, setSearch] = useState('')
const [deletingId, setDeletingId] = useState<string | null>(null)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
function UsersDimentorinPage() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = React.useState<'mentor' | 'mentee'>(
'mentor'
);
const [search, setSearch] = React.useState('');
const [deletingId, setDeletingId] = React.useState<string | null>(null);
const [rowSelection, setRowSelection] =
React.useState<RowSelectionState>({});
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
})
pageSize: 10,
});
const { data: mentorData, isLoading: mentorLoading } = useMentorList({
search,
page: pagination.pageIndex + 1,
per_page: pagination.pageSize,
})
});
const { data: menteeData, isLoading: menteeLoading } = useUserList({
search,
page: pagination.pageIndex + 1,
per_page: pagination.pageSize,
})
});
const deleteMentor = useDeleteMentor();
const deleteMentor = useDeleteMentor()
const mentors: MentorDetailResponseDto[] = mentorData?.data ?? []
const mentees: TUsersListItem[] = menteeData?.data ?? []
const mentorTotal = mentorData?.meta?.total ?? mentors.length
const menteeTotal = menteeData?.meta?.total ?? mentees.length
const isLoading = activeTab === 'mentor' ? mentorLoading : menteeLoading
const totalItems = activeTab === 'mentor' ? mentorTotal : menteeTotal
const mentors: MentorDetailResponseDto[] = mentorData?.data ?? [];
const mentees: TUsersListItem[] = menteeData?.data ?? [];
const mentorTotal = mentorData?.meta?.total ?? mentors.length;
const menteeTotal = menteeData?.meta?.total ?? mentees.length;
const handleDelete = async (id: string) => {
try {
await deleteMentor.mutateAsync(id)
toast.success('Akun berhasil dihapus')
setDeletingId(null)
await deleteMentor.mutateAsync(id);
toast.success('Akun berhasil dihapus');
setDeletingId(null);
} catch (error) {
console.log(error)
toast.error('Gagal menghapus akun')
console.log(error);
toast.error('Gagal menghapus akun');
}
}
};
const statusVariantMap: Record<
string,
'success' | 'warning' | 'destructive' | 'secondary'
> = {
active: 'success',
pending: 'warning',
inactive: 'destructive',
};
const mentorColumns: ColumnDef<MentorDetailResponseDto>[] = [
{
id: 'select',
meta: { cellClassName: cn('w-20') },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'name',
header: 'Name',
accessorKey: 'fullname',
},
{
id: 'email',
header: 'Email',
accessorKey: 'email',
meta: { cellClassName: cn('w-10') },
header: ({ table }) => <SelectAllCheckbox table={table} />,
cell: ({ row }) => <RowSelectCheckbox row={row} />,
},
{ id: 'name', header: 'Name', accessorKey: 'fullname' },
{ id: 'email', header: 'Email', accessorKey: 'email' },
{
id: 'rating',
header: 'Rating',
@@ -116,139 +115,86 @@ function UsersDimentorinPage(): ReactElement {
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status
const statusColors: Record<string, string> = {
active: 'bg-success-200 text-success-500',
pending: 'bg-warning-200 text-warning-700',
inactive: 'bg-danger-200 text-danger-500',
}
const status = row.original.status ?? '';
return (
<div className={`py-2 px-4 rounded-md text-center capitalize ${statusColors[status] ?? 'bg-neutral-200 text-neutral-700'}`}>
{status}
</div>
)
<Badge variant={statusVariantMap[status] ?? 'secondary'} className="capitalize">
{status || 'unknown'}
</Badge>
);
},
},
{
header: 'Action',
meta: { cellClassName: cn('w-72') },
cell: ({ row }) => (
<div className="flex gap-[8px]">
{deletingId === row.original.id ? (
<>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleDelete(row.original.id)
}}
className="flex items-center gap-2"
>
Konfirmasi
</Button>
<Button
variant="bordered"
size="sm"
onClick={(e) => {
e.stopPropagation()
setDeletingId(null)
}}
className="flex items-center gap-2"
>
Batal
</Button>
</>
) : (
<>
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation()
navigate({ to: '/users-dimentorin/$id', params: { id: row.original.id } })
}}
className="flex items-center gap-2 w-max"
>
<SearchOutlined className="text-[16px]" /> Lihat Detail
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation()
setDeletingId(row.original.id)
}}
className="flex items-center gap-2"
>
<DeleteOutlined />
</Button>
</>
)}
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
navigate({
to: '/users-dimentorin/$id',
params: { id: row.original.id },
});
}}
>
<Eye className="size-3.5" />
Detail
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
setDeletingId(row.original.id);
}}
>
<Trash2 className="size-3.5" />
</Button>
</div>
),
},
]
];
const menteeColumns: ColumnDef<TUsersListItem>[] = [
{
id: 'select',
meta: { cellClassName: cn('w-20') },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'name',
header: 'Name',
accessorKey: 'fullname',
},
{
id: 'email',
header: 'Email',
accessorKey: 'email',
meta: { cellClassName: cn('w-10') },
header: ({ table }) => <SelectAllCheckbox table={table} />,
cell: ({ row }) => <RowSelectCheckbox row={row} />,
},
{ id: 'name', header: 'Name', accessorKey: 'fullname' },
{ id: 'email', header: 'Email', accessorKey: 'email' },
{
id: 'status',
header: 'Status',
accessorKey: 'is_active',
cell: ({ row }) => (
<div className={`py-2 px-4 rounded-md text-center ${row.original.is_active ? 'bg-success-200 text-success-500' : 'bg-danger-200 text-danger-500'}`}>
<Badge variant={row.original.is_active ? 'success' : 'destructive'}>
{row.original.is_active ? 'Active' : 'Inactive'}
</div>
</Badge>
),
},
{
header: 'Action',
meta: { cellClassName: cn('w-72') },
cell: ({ row }) => (
<Button
variant="primary"
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation()
navigate({ to: '/users-dimentorin/$id', params: { id: row.original.id } })
e.stopPropagation();
navigate({
to: '/users-dimentorin/$id',
params: { id: row.original.id },
});
}}
className="flex items-center gap-2 w-max"
>
<SearchOutlined className="text-[16px]" /> Lihat Detail
<Eye className="size-3.5" />
Detail
</Button>
),
},
]
];
const mentorTable = useReactTable({
data: mentors,
@@ -261,7 +207,7 @@ function UsersDimentorinPage(): ReactElement {
onPaginationChange: setPagination,
pageCount: Math.ceil(mentorTotal / pagination.pageSize),
manualPagination: true,
})
});
const menteeTable = useReactTable({
data: mentees,
@@ -274,59 +220,91 @@ function UsersDimentorinPage(): ReactElement {
onPaginationChange: setPagination,
pageCount: Math.ceil(menteeTotal / pagination.pageSize),
manualPagination: true,
})
});
return (
<BackofficeWrapper title="Dimentorin.dev">
<div className="mb-8 flex justify-between items-center">
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">
User Management
</h1>
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
<For data={TABS}>
{(tab) => (
<Button
key={tab}
variant="text"
className={cn(
'px-3 py-2 capitalize',
activeTab === tab && 'bg-white'
)}
onClick={() => {
setActiveTab(tab)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
>
{tab}
</Button>
)}
</For>
</div>
</div>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-5 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama lengkap"
className="pl-12 w-full max-h-full"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
<BackofficeWrapper
title="Users Dimentorin"
description="Manajemen mentor dan mentee"
>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Cari nama lengkap…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<Tabs
value={activeTab}
onValueChange={(v) => {
setActiveTab(v as 'mentor' | 'mentee');
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
<TabsList>
<TabsTrigger value="mentor" className="capitalize">
Mentor ({mentorTotal})
</TabsTrigger>
<TabsTrigger value="mentee" className="capitalize">
Mentee ({menteeTotal})
</TabsTrigger>
</TabsList>
<TabsContent value="mentor" className="mt-4">
{mentorLoading ? (
<div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : (
<DataTable
data={mentors}
columns={mentorColumns}
table={mentorTable}
manualPagination
pageCount={Math.ceil(mentorTotal / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</TabsContent>
<TabsContent value="mentee" className="mt-4">
{menteeLoading ? (
<div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : (
<DataTable
data={mentees}
columns={menteeColumns}
table={menteeTable}
manualPagination
pageCount={Math.ceil(menteeTotal / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
{isLoading ? (
<div className="text-center py-8 text-neutral-400">Loading...</div>
) : activeTab === 'mentor' ? (
<DataTable data={mentors} columns={mentorColumns} table={mentorTable} />
) : (
<DataTable data={mentees} columns={menteeColumns} table={menteeTable} />
)}
</section>
<DeleteConfirmDialog
open={!!deletingId}
onOpenChange={(o) => !o && setDeletingId(null)}
onConfirm={() => deletingId && handleDelete(deletingId)}
title="Hapus akun mentor ini?"
/>
</BackofficeWrapper>
)
);
}
+26 -8
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 { useEffect } from 'react'
export const Route = createFileRoute('/')({
beforeLoad: () => {
const session = SessionToken.get()
if (session?.token?.access_token) {
throw redirect({ to: '/hackathon-dashboard' })
}
throw redirect({ to: '/auth/login' })
},
component: IndexRedirect,
})
function IndexRedirect() {
const navigate = useNavigate()
useEffect(() => {
try {
const session = SessionToken.get()
if (session?.token?.access_token) {
navigate({ to: '/hackathon-dashboard' })
} else {
navigate({ to: '/auth/login' })
}
} catch {
navigate({ to: '/auth/login' })
}
}, [navigate])
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<div style={{ width: 40, height: 40, border: '3px solid #e5e7eb', borderTopColor: '#3b82f6', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
</div>
)
}
+6 -1
View File
@@ -16,7 +16,9 @@ export default defineConfig(() => ({
port: 3001,
host: 'localhost',
},
build: { rolldownOptions: { output: { codeSplitting: { groups: [{ name: 'vendor', test: /node_modules/ }] } } } },
resolve: {
dedupe: ['react', 'react-dom', '@tanstack/react-router', '@tanstack/react-query'],
},
plugins: [
TanStackRouterVite({
routeFileIgnorePattern: '_components|_hooks|_hook|_data',
@@ -32,6 +34,9 @@ export default defineConfig(() => ({
commonjsOptions: {
transformMixedEsModules: true,
},
rolldownOptions: {
output: { codeSplitting: { groups: [{ name: 'vendor', test: /node_modules/ }] } },
},
},
test: {
watch: false,
+3 -1
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-label1: 0.98rem; /* ~15.74px */
--text-label2: 0.78rem; /* ~12.59px */
/* Custom Colors for CSS Hex Exact Matches - IMPHNEN Dimentorin Brand */
--color-bg-light-blue: #f7fbff; /* Primary dashboard background */
--color-primary-accent: #23a1eb; /* Primary interactive color (buttons, links, active states) */
--color-text-dark: #1a1a1a; /* Ultra-dark text (auth titles, headers) */
--color-text-secondary: #6d6d6d; /* Secondary text (subtitles, descriptions) */
--color-text-label: #454545; /* Form labels and descriptive labels */
--color-text-muted: #888888; /* Muted text (nav items, metric labels) */
--color-border-light: #bce1fb; /* Light blue borders (inputs, tabs) */
--color-placeholder: #b0b0b0; /* Input placeholder text, disabled backgrounds */
--color-bg-hover: #f6f6f6; /* Hover state backgrounds (nav items) */
--color-divider-blue: #81cbf8; /* Divider lines and gradient fills */
--color-border-subtle: #e5e5e5; /* Very light borders (buttons) */
--color-bg-secondary: #f8f8f8; /* Secondary backgrounds (alt button) */
--color-bg-placeholder: #d9d9d9; /* Placeholder backgrounds (avatars, images) */
--color-text-tab-default: #4f4f4f; /* Tab button text color */
}
@layer base {
@@ -124,4 +140,8 @@
.scrollbar-hide::-webkit-scrollbar {
@apply hidden;
}
.shadow-auth {
box-shadow: 0 10px 40px rgba(35, 161, 235, 0.1);
}
}
+122 -19
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 SiteArticlesRouteImport } from './routes/_site/articles'
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
import { Route as AuthenticatedDashboardIndexRouteImport } from './routes/_authenticated/dashboard_/index'
import { Route as AuthenticatedDashboardIndexRouteImport } from './routes/_authenticated/dashboard/index'
import { Route as SiteProfileIdRouteImport } from './routes/_site/profile_/$id'
import { Route as SiteMentoringIdRouteImport } from './routes/_site/mentoring_/$id'
import { Route as SiteArticlesSlugRouteImport } from './routes/_site/articles_/$slug'
@@ -29,6 +29,10 @@ import { Route as PublicAuthGoogleOauthPopupRouteImport } from './routes/_public
import { Route as PublicAuthGoogleCallbackRouteImport } from './routes/_public/auth/google-callback'
import { Route as PublicAuthForgotRouteImport } from './routes/_public/auth/forgot'
import { Route as AuthenticatedDashboardSettingsRouteImport } from './routes/_authenticated/dashboard_/settings'
import { Route as AuthenticatedDashboardRoadmapDiscoveryRouteImport } from './routes/_authenticated/dashboard/roadmap-discovery'
import { Route as AuthenticatedDashboardMentoringRouteImport } from './routes/_authenticated/dashboard/mentoring'
import { Route as AuthenticatedDashboardLearningPathRouteImport } from './routes/_authenticated/dashboard/learning-path'
import { Route as AuthenticatedDashboardArticleBuilderRouteImport } from './routes/_authenticated/dashboard/article-builder'
import { Route as PublicAuthRegisterSuccessRouteImport } from './routes/_public/auth/register_/success'
import { Route as PublicAuthRegisterOtpRouteImport } from './routes/_public/auth/register_/otp'
import { Route as PublicAuthRegisterMentorSuccessRouteImport } from './routes/_public/auth/register-mentor_/success'
@@ -80,9 +84,9 @@ const AuthenticatedDashboardRoute = AuthenticatedDashboardRouteImport.update({
} as any)
const AuthenticatedDashboardIndexRoute =
AuthenticatedDashboardIndexRouteImport.update({
id: '/dashboard_/',
path: '/dashboard/',
getParentRoute: () => AuthenticatedRoute,
id: '/',
path: '/',
getParentRoute: () => AuthenticatedDashboardRoute,
} as any)
const SiteProfileIdRoute = SiteProfileIdRouteImport.update({
id: '/profile_/$id',
@@ -138,6 +142,30 @@ const AuthenticatedDashboardSettingsRoute =
path: '/dashboard/settings',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedDashboardRoadmapDiscoveryRoute =
AuthenticatedDashboardRoadmapDiscoveryRouteImport.update({
id: '/roadmap-discovery',
path: '/roadmap-discovery',
getParentRoute: () => AuthenticatedDashboardRoute,
} as any)
const AuthenticatedDashboardMentoringRoute =
AuthenticatedDashboardMentoringRouteImport.update({
id: '/mentoring',
path: '/mentoring',
getParentRoute: () => AuthenticatedDashboardRoute,
} as any)
const AuthenticatedDashboardLearningPathRoute =
AuthenticatedDashboardLearningPathRouteImport.update({
id: '/learning-path',
path: '/learning-path',
getParentRoute: () => AuthenticatedDashboardRoute,
} as any)
const AuthenticatedDashboardArticleBuilderRoute =
AuthenticatedDashboardArticleBuilderRouteImport.update({
id: '/article-builder',
path: '/article-builder',
getParentRoute: () => AuthenticatedDashboardRoute,
} as any)
const PublicAuthRegisterSuccessRoute =
PublicAuthRegisterSuccessRouteImport.update({
id: '/auth/register_/success',
@@ -174,11 +202,15 @@ const PublicAuthForgotOtpRoute = PublicAuthForgotOtpRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/dashboard': typeof AuthenticatedDashboardRoute
'/dashboard': typeof AuthenticatedDashboardRouteWithChildren
'/articles': typeof SiteArticlesRoute
'/mentoring': typeof SiteMentoringRoute
'/profile': typeof SiteProfileRoute
'/resources': typeof SiteResourcesRoute
'/dashboard/article-builder': typeof AuthenticatedDashboardArticleBuilderRoute
'/dashboard/learning-path': typeof AuthenticatedDashboardLearningPathRoute
'/dashboard/mentoring': typeof AuthenticatedDashboardMentoringRoute
'/dashboard/roadmap-discovery': typeof AuthenticatedDashboardRoadmapDiscoveryRoute
'/dashboard/settings': typeof AuthenticatedDashboardSettingsRoute
'/auth/forgot': typeof PublicAuthForgotRoute
'/auth/google-callback': typeof PublicAuthGoogleCallbackRoute
@@ -199,11 +231,14 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/dashboard': typeof AuthenticatedDashboardIndexRoute
'/articles': typeof SiteArticlesRoute
'/mentoring': typeof SiteMentoringRoute
'/profile': typeof SiteProfileRoute
'/resources': typeof SiteResourcesRoute
'/dashboard/article-builder': typeof AuthenticatedDashboardArticleBuilderRoute
'/dashboard/learning-path': typeof AuthenticatedDashboardLearningPathRoute
'/dashboard/mentoring': typeof AuthenticatedDashboardMentoringRoute
'/dashboard/roadmap-discovery': typeof AuthenticatedDashboardRoadmapDiscoveryRoute
'/dashboard/settings': typeof AuthenticatedDashboardSettingsRoute
'/auth/forgot': typeof PublicAuthForgotRoute
'/auth/google-callback': typeof PublicAuthGoogleCallbackRoute
@@ -214,6 +249,7 @@ export interface FileRoutesByTo {
'/articles/$slug': typeof SiteArticlesSlugRoute
'/mentoring/$id': typeof SiteMentoringIdRoute
'/profile/$id': typeof SiteProfileIdRoute
'/dashboard': typeof AuthenticatedDashboardIndexRoute
'/auth/forgot/otp': typeof PublicAuthForgotOtpRoute
'/auth/forgot/summon': typeof PublicAuthForgotSummonRoute
'/auth/register-mentor/pending': typeof PublicAuthRegisterMentorPendingRoute
@@ -227,11 +263,15 @@ export interface FileRoutesById {
'/_authenticated': typeof AuthenticatedRouteWithChildren
'/_public': typeof PublicRouteWithChildren
'/_site': typeof SiteRouteWithChildren
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
'/_authenticated/dashboard': typeof AuthenticatedDashboardRouteWithChildren
'/_site/articles': typeof SiteArticlesRoute
'/_site/mentoring': typeof SiteMentoringRoute
'/_site/profile': typeof SiteProfileRoute
'/_site/resources': typeof SiteResourcesRoute
'/_authenticated/dashboard/article-builder': typeof AuthenticatedDashboardArticleBuilderRoute
'/_authenticated/dashboard/learning-path': typeof AuthenticatedDashboardLearningPathRoute
'/_authenticated/dashboard/mentoring': typeof AuthenticatedDashboardMentoringRoute
'/_authenticated/dashboard/roadmap-discovery': typeof AuthenticatedDashboardRoadmapDiscoveryRoute
'/_authenticated/dashboard_/settings': typeof AuthenticatedDashboardSettingsRoute
'/_public/auth/forgot': typeof PublicAuthForgotRoute
'/_public/auth/google-callback': typeof PublicAuthGoogleCallbackRoute
@@ -242,7 +282,7 @@ export interface FileRoutesById {
'/_site/articles_/$slug': typeof SiteArticlesSlugRoute
'/_site/mentoring_/$id': typeof SiteMentoringIdRoute
'/_site/profile_/$id': typeof SiteProfileIdRoute
'/_authenticated/dashboard_/': typeof AuthenticatedDashboardIndexRoute
'/_authenticated/dashboard/': typeof AuthenticatedDashboardIndexRoute
'/_public/auth/forgot_/otp': typeof PublicAuthForgotOtpRoute
'/_public/auth/forgot_/summon': typeof PublicAuthForgotSummonRoute
'/_public/auth/register-mentor_/pending': typeof PublicAuthRegisterMentorPendingRoute
@@ -259,6 +299,10 @@ export interface FileRouteTypes {
| '/mentoring'
| '/profile'
| '/resources'
| '/dashboard/article-builder'
| '/dashboard/learning-path'
| '/dashboard/mentoring'
| '/dashboard/roadmap-discovery'
| '/dashboard/settings'
| '/auth/forgot'
| '/auth/google-callback'
@@ -279,11 +323,14 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/dashboard'
| '/articles'
| '/mentoring'
| '/profile'
| '/resources'
| '/dashboard/article-builder'
| '/dashboard/learning-path'
| '/dashboard/mentoring'
| '/dashboard/roadmap-discovery'
| '/dashboard/settings'
| '/auth/forgot'
| '/auth/google-callback'
@@ -294,6 +341,7 @@ export interface FileRouteTypes {
| '/articles/$slug'
| '/mentoring/$id'
| '/profile/$id'
| '/dashboard'
| '/auth/forgot/otp'
| '/auth/forgot/summon'
| '/auth/register-mentor/pending'
@@ -311,6 +359,10 @@ export interface FileRouteTypes {
| '/_site/mentoring'
| '/_site/profile'
| '/_site/resources'
| '/_authenticated/dashboard/article-builder'
| '/_authenticated/dashboard/learning-path'
| '/_authenticated/dashboard/mentoring'
| '/_authenticated/dashboard/roadmap-discovery'
| '/_authenticated/dashboard_/settings'
| '/_public/auth/forgot'
| '/_public/auth/google-callback'
@@ -321,7 +373,7 @@ export interface FileRouteTypes {
| '/_site/articles_/$slug'
| '/_site/mentoring_/$id'
| '/_site/profile_/$id'
| '/_authenticated/dashboard_/'
| '/_authenticated/dashboard/'
| '/_public/auth/forgot_/otp'
| '/_public/auth/forgot_/summon'
| '/_public/auth/register-mentor_/pending'
@@ -402,12 +454,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedDashboardRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/dashboard_/': {
id: '/_authenticated/dashboard_/'
path: '/dashboard'
'/_authenticated/dashboard/': {
id: '/_authenticated/dashboard/'
path: '/'
fullPath: '/dashboard/'
preLoaderRoute: typeof AuthenticatedDashboardIndexRouteImport
parentRoute: typeof AuthenticatedRoute
parentRoute: typeof AuthenticatedDashboardRoute
}
'/_site/profile_/$id': {
id: '/_site/profile_/$id'
@@ -479,6 +531,34 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedDashboardSettingsRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/dashboard/roadmap-discovery': {
id: '/_authenticated/dashboard/roadmap-discovery'
path: '/roadmap-discovery'
fullPath: '/dashboard/roadmap-discovery'
preLoaderRoute: typeof AuthenticatedDashboardRoadmapDiscoveryRouteImport
parentRoute: typeof AuthenticatedDashboardRoute
}
'/_authenticated/dashboard/mentoring': {
id: '/_authenticated/dashboard/mentoring'
path: '/mentoring'
fullPath: '/dashboard/mentoring'
preLoaderRoute: typeof AuthenticatedDashboardMentoringRouteImport
parentRoute: typeof AuthenticatedDashboardRoute
}
'/_authenticated/dashboard/learning-path': {
id: '/_authenticated/dashboard/learning-path'
path: '/learning-path'
fullPath: '/dashboard/learning-path'
preLoaderRoute: typeof AuthenticatedDashboardLearningPathRouteImport
parentRoute: typeof AuthenticatedDashboardRoute
}
'/_authenticated/dashboard/article-builder': {
id: '/_authenticated/dashboard/article-builder'
path: '/article-builder'
fullPath: '/dashboard/article-builder'
preLoaderRoute: typeof AuthenticatedDashboardArticleBuilderRouteImport
parentRoute: typeof AuthenticatedDashboardRoute
}
'/_public/auth/register_/success': {
id: '/_public/auth/register_/success'
path: '/auth/register/success'
@@ -524,16 +604,39 @@ declare module '@tanstack/react-router' {
}
}
interface AuthenticatedRouteChildren {
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
AuthenticatedDashboardSettingsRoute: typeof AuthenticatedDashboardSettingsRoute
interface AuthenticatedDashboardRouteChildren {
AuthenticatedDashboardArticleBuilderRoute: typeof AuthenticatedDashboardArticleBuilderRoute
AuthenticatedDashboardLearningPathRoute: typeof AuthenticatedDashboardLearningPathRoute
AuthenticatedDashboardMentoringRoute: typeof AuthenticatedDashboardMentoringRoute
AuthenticatedDashboardRoadmapDiscoveryRoute: typeof AuthenticatedDashboardRoadmapDiscoveryRoute
AuthenticatedDashboardIndexRoute: typeof AuthenticatedDashboardIndexRoute
}
const AuthenticatedDashboardRouteChildren: AuthenticatedDashboardRouteChildren =
{
AuthenticatedDashboardArticleBuilderRoute:
AuthenticatedDashboardArticleBuilderRoute,
AuthenticatedDashboardLearningPathRoute:
AuthenticatedDashboardLearningPathRoute,
AuthenticatedDashboardMentoringRoute: AuthenticatedDashboardMentoringRoute,
AuthenticatedDashboardRoadmapDiscoveryRoute:
AuthenticatedDashboardRoadmapDiscoveryRoute,
AuthenticatedDashboardIndexRoute: AuthenticatedDashboardIndexRoute,
}
const AuthenticatedDashboardRouteWithChildren =
AuthenticatedDashboardRoute._addFileChildren(
AuthenticatedDashboardRouteChildren,
)
interface AuthenticatedRouteChildren {
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRouteWithChildren
AuthenticatedDashboardSettingsRoute: typeof AuthenticatedDashboardSettingsRoute
}
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
AuthenticatedDashboardRoute: AuthenticatedDashboardRouteWithChildren,
AuthenticatedDashboardSettingsRoute: AuthenticatedDashboardSettingsRoute,
AuthenticatedDashboardIndexRoute: AuthenticatedDashboardIndexRoute,
}
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
@@ -3,6 +3,13 @@ import { SessionToken } from '@imphnen-frontend-service/service'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: () => {
// Development-only bypass explicitly requested via environment flags
const bypassAuth = import.meta.env.MODE === 'development' && import.meta.env.VITE_BYPASS_AUTH_MIDDLEWARE === 'true'
if (bypassAuth) {
return
}
const session = SessionToken.get()
if (!session?.token?.access_token) {
throw redirect({ to: '/auth/login' })
@@ -1,65 +1,228 @@
import { createFileRoute, Outlet, Link, useLocation, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { useAuthStore } from '@imphnen-frontend-service/service'
import { Icon } from '@iconify/react'
import { resolvePersona } from './dashboard/_data/persona-resolver'
const navItems = [
{ path: '/dashboard', label: 'Dashboard', icon: 'mdi:view-dashboard' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'mdi:cog' },
]
interface NavItem {
path?: string;
label: string;
icon: string;
isLink?: boolean;
}
/**
* Get navigation items based on persona.
* Only items with isLink=true will use Link component; others render as non-navigating buttons.
*/
function getNavItems(persona: 'user' | 'mentor'): NavItem[] {
if (persona === 'mentor') {
return [
{ path: '/dashboard', label: 'Dashboard', icon: 'mdi:view-dashboard-outline', isLink: true },
{ path: '/dashboard/mentoring-setup', label: 'Mentoring Setup', icon: 'mdi:cog-outline', isLink: false },
{ path: '/dashboard/list-mentee', label: 'List Mentee', icon: 'mdi:account-group-outline', isLink: false },
{ path: '/dashboard/feedback', label: 'Feedback', icon: 'mdi:message-reply-text-outline', isLink: false },
];
}
return [
{ path: '/dashboard', label: 'Dashboard', icon: 'mdi:view-dashboard-outline', isLink: true },
{ path: '/dashboard/roadmap-discovery', label: 'Roadmap Discovery', icon: 'mdi:map-marker-path', isLink: true },
{ path: '/dashboard/learning-path', label: 'Learning Path', icon: 'mdi:book-open-page-variant-outline', isLink: true },
{ path: '/dashboard/mentoring', label: 'Mentoring', icon: 'mdi:video-outline', isLink: true },
];
}
export const Route = createFileRoute('/_authenticated/dashboard')({
component: DashboardLayout,
})
/**
* Header component for the dashboard, containing the app brand and user profile.
*/
function HeaderDashboard({ persona, user, onLogout }: { persona: 'user' | 'mentor', user: any, onLogout: () => void }) {
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false)
const notifications = [
{ id: 1, title: 'Mentoring Sesi Baru', message: 'Kamu punya sesi mentoring besok jam 20:00 WIB', time: '2 jam yang lalu', unread: true },
{ id: 2, title: 'Artikel Disetujui', message: 'Artikel "How to install linux" kamu telah disetujui mentor', time: '5 jam yang lalu', unread: false },
{ id: 3, title: 'Roadmap Selesai', message: 'Selamat! Kamu telah menyelesaikan roadmap Front-end Basic', time: '1 hari yang lalu', unread: false },
]
return (
<header className="h-[58px] w-[972px] mx-auto mt-[52px] mb-[62px] bg-white rounded-sm shadow-sm flex items-center justify-between px-5 relative">
<Link to="/dashboard" className="text-[19px] font-semibold text-primary-accent">
Dimentorin.dev
</Link>
<div className="flex items-center gap-4">
<div className="relative">
<button
onClick={() => setIsNotificationsOpen(!isNotificationsOpen)}
className={`w-7 h-7 rounded-sm flex items-center justify-center cursor-pointer transition-colors ${
isNotificationsOpen ? 'bg-primary-50 text-primary-accent' : 'bg-white text-neutral-600'
}`}
>
<Icon icon="lucide:bell" width="16" />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full border-2 border-white" />
</button>
{isNotificationsOpen && (
<div className="absolute right-0 top-full mt-2 w-[320px] bg-white rounded-xl shadow-2xl border border-gray-100 z-50 overflow-hidden animate-in fade-in slide-in-from-top-2">
<div className="p-4 border-b border-gray-50 flex items-center justify-between">
<h3 className="font-bold text-gray-900">Notifikasi</h3>
<button className="text-xs text-primary-600 font-medium hover:underline cursor-pointer">Tandai semua dibaca</button>
</div>
<div className="max-h-[400px] overflow-y-auto">
{notifications.map((n) => (
<div key={n.id} className={`p-4 border-b border-gray-50 last:border-0 hover:bg-gray-50 transition-colors cursor-pointer ${n.unread ? 'bg-primary-50/30' : ''}`}>
<div className="flex justify-between items-start mb-1">
<p className={`text-sm font-bold ${n.unread ? 'text-gray-900' : 'text-gray-700'}`}>{n.title}</p>
<span className="text-[10px] text-gray-400 whitespace-nowrap">{n.time}</span>
</div>
<p className="text-xs text-gray-500 line-clamp-2">{n.message}</p>
</div>
))}
</div>
<div className="p-3 bg-gray-50 text-center">
<button className="text-xs font-bold text-gray-600 hover:text-primary-600 transition-colors cursor-pointer">Lihat Semua Notifikasi</button>
</div>
</div>
)}
</div>
<Link
to="/dashboard/settings"
className="w-7 h-7 rounded-sm bg-white text-neutral-600 flex items-center justify-center cursor-pointer"
>
<Icon icon="lucide:settings" width="16" />
</Link>
<Link
to="/profile"
className="h-[42px] flex items-center gap-3 pl-2.5 cursor-pointer border-none bg-transparent"
>
<div className="flex flex-col items-end text-right">
<span className="text-xs font-medium text-neutral-600">{user?.fullname || 'User'}</span>
<span className="text-[10px] font-medium text-neutral-600">{persona === 'mentor' ? 'Mentor' : 'Mentee'}</span>
</div>
<div
className="w-7 h-7 rounded-full bg-bg-placeholder bg-cover bg-center"
style={user?.avatar ? { backgroundImage: `url(${user.avatar})` } : {}}
/>
</Link>
</div>
</header>
);
}
/**
* Dashboard layout shell with persona-aware sidebar navigation.
* Renders navigation and outlet for nested routes.
*/
function DashboardLayout() {
const { session, clearSession } = useAuthStore()
const location = useLocation()
const navigate = useNavigate()
const searchParams = new URLSearchParams(location.search);
const persona = resolvePersona(session?.user, searchParams);
const navItems = getNavItems(persona);
const handleLogout = () => {
clearSession();
navigate({ to: '/auth/login' });
};
const isNavItemActive = (path?: string) => {
if (!path) return false;
// Dashboard should only be active on the dashboard index route.
if (path === '/dashboard') {
return location.pathname === '/dashboard' || location.pathname === '/dashboard/';
}
return location.pathname.startsWith(path);
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<nav className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-6">
<Link to="/" className="text-xl font-bold text-primary-600 dark:text-primary-400">
Dimentorin
</Link>
<div className="hidden md:flex gap-1">
{navItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={`px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-colors ${
location.pathname === item.path
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
<Icon icon={item.icon} width="18" />
{item.label}
</Link>
))}
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:block">
{session?.user?.fullname || session?.user?.email}
</span>
<button
onClick={() => { clearSession(); navigate({ to: '/auth/login' }) }}
className="text-sm text-red-500 hover:text-red-600 cursor-pointer flex items-center gap-1"
>
<Icon icon="mdi:logout" width="18" />
<span className="hidden sm:inline">Logout</span>
</button>
</div>
<div className="min-h-screen bg-bg-light-blue flex">
{/* Sidebar Navigation */}
<aside className="w-[228px] bg-white flex flex-col sticky top-0 h-screen z-100">
{/* Logo */}
<div className="pt-[60px] px-6 pb-8">
<div className="h-12 flex items-center justify-center">
<img
src="/logos/logo.svg"
alt="Dimentorin"
style={{ width: '128px', height: '48px', objectFit: 'contain' }}
/>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Outlet />
</main>
{/* Navigation Items */}
<nav className="flex-1 px-6 flex flex-col gap-2">
{navItems.map((item) => {
const isActive = item.isLink ? isNavItemActive(item.path) : false;
const baseClasses = 'h-8 px-3 rounded-sm flex items-center gap-3 cursor-pointer text-xs font-medium leading-[1.3] text-text-muted transition-all duration-200 hover:bg-bg-hover';
const activeClasses = isActive ? 'bg-primary-accent text-white' : '';
if (item.isLink && item.path) {
return (
<Link
key={item.path}
to={item.path}
className={`${baseClasses} ${activeClasses}`}
>
<Icon
icon={item.icon}
width="16"
className={isActive ? 'text-white' : 'text-text-muted'}
style={isActive ? { color: '#ffffff' } : {}}
/>
{item.label}
</Link>
);
}
return (
<button
key={item.label}
className={baseClasses}
type="button"
disabled
>
<Icon icon={item.icon} width="16" />
{item.label}
</button>
);
})}
</nav>
{/* Sidebar Footer */}
<div className="flex flex-col pt-4 px-6 pb-[34px]">
<div className="h-px bg-border-light mb-4" />
<button
onClick={handleLogout}
className="h-8 px-3 rounded-sm flex items-center gap-3 cursor-pointer text-xs font-medium leading-[1.3] text-text-muted transition-all duration-200 hover:bg-bg-hover"
>
<Icon icon="mdi:logout" width="16" />
Log Out
</button>
</div>
</aside>
{/* Main Content Area */}
<div className="flex-1 flex flex-col min-w-0">
<HeaderDashboard
persona={persona}
user={session?.user}
onLogout={handleLogout}
/>
<main className="w-[1052px] mx-auto px-10 pt-6 pb-10">
<Outlet />
</main>
</div>
</div>
)
}
@@ -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