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>
This commit is contained in:
Maulana SodiqinandClaude Opus 4.6 committed 2026-04-12 23:01:47 +07:00
1 parent 53079fe868
commit 63dfb7299f
82 files changed
+7961 -4195

No files matched your search

@@ -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>
);
}
+182 -273
View File
@@ -1,324 +1,233 @@
import { 'use client';
AppstoreOutlined,
AuditOutlined,
BookOutlined,
CalendarOutlined,
CommentOutlined,
DownOutlined,
InboxOutlined,
LogoutOutlined,
MessageOutlined,
ReadOutlined,
ReloadOutlined,
RightOutlined,
ScheduleOutlined,
SettingOutlined,
StockOutlined,
UsergroupAddOutlined,
UserOutlined,
UserSwitchOutlined,
} from '@ant-design/icons';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { FC, ReactElement, useState } from 'react'; import { FC, ReactElement, useState } from 'react';
import { useLocation, useNavigate } from '@tanstack/react-router'; import { useLocation, useNavigate } from '@tanstack/react-router';
import { cn, For } from '@imphnen-frontend-service/utils'; import {
import { useSession } from '@imphnen-frontend-service/service'; 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 MenuItem = { type MenuLink = {
label: string; label: string;
href?: string; href: string;
icon?: ReactElement; icon: React.ComponentType<{ className?: string }>;
children?: Array<{ label: string; href: string; icon?: ReactElement }>;
}; };
type MenuGroup = {
label: string;
icon: React.ComponentType<{ className?: string }>;
children: MenuLink[];
};
type MenuItem = MenuLink | MenuGroup;
const MENUS: MenuItem[] = [ const MENUS: MenuItem[] = [
{ {
label: 'Hackathon', label: 'Hackathon',
icon: <StockOutlined className="text-p3" />, icon: BarChart3,
children: [ children: [
{ { label: 'Dashboard', href: '/hackathon-dashboard', icon: LayoutDashboard },
label: 'Dashboard', { label: 'Users', href: '/hackathon-users', icon: Users },
href: '/hackathon-dashboard', { label: 'Teams', href: '/hackathon-teams', icon: UsersRound },
icon: <AppstoreOutlined className="text-p3" />, { label: 'Submissions', href: '/hackathon-submissions', icon: ClipboardCheck },
},
{
label: 'Users',
href: '/hackathon-users',
icon: <UserOutlined className="text-p3" />,
},
{
label: 'Teams',
href: '/hackathon-teams',
icon: <UsergroupAddOutlined className="text-p3" />,
},
{
label: 'Submissions',
href: '/hackathon-submissions',
icon: <AuditOutlined className="text-p3" />,
},
], ],
}, },
{ {
label: 'Dimentorin', label: 'Dimentorin',
icon: <ReadOutlined className="text-p3" />, icon: BookOpen,
children: [ children: [
{ { label: 'Dashboard', href: '/dashboard-dimentorin', icon: LayoutDashboard },
label: 'Dashboard - Dimentorin', { label: 'Users', href: '/users-dimentorin', icon: UserCog },
href: '/dashboard-dimentorin', { label: 'Session', href: '/session-dimentorin', icon: CalendarClock },
icon: <AppstoreOutlined className="text-[20px]" />, { 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: 'User - Dimentorin',
href: '/users-dimentorin',
icon: <UserSwitchOutlined className="text-[20px]" />,
},
{
label: 'Session - Dimentorin',
href: '/session-dimentorin',
icon: <ScheduleOutlined className="text-[20px]" />,
},
{
label: 'Content & Roadmap',
href: '/roadmap-dimentorin',
icon: <BookOutlined className="text-[20px]" />,
},
{
label: 'Feedback & Review',
href: '/feedback-review-dimentorin',
icon: <CommentOutlined className="text-[20px]" />,
},
{
label: 'Settings - Dimentorin',
href: '/settings-dimentorin',
icon: <SettingOutlined className="text-[20px]" />,
},
], ],
}, },
{ {
label: 'Gacha', label: 'Gacha',
icon: <ReloadOutlined className="text-[20px]" />, icon: RefreshCcw,
children: [ children: [
{ { label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
label: 'Dashboard & Set Gacha', { label: 'Gacha Roll', href: '/gacha-roll', icon: RefreshCcw },
href: '/dashboard', { label: 'Validasi Transaksi', href: '/transactions', icon: ClipboardCheck },
icon: <AppstoreOutlined className="text-[20px]" />, { label: 'Data Pengiriman', href: '/prizes', icon: Inbox },
},
{
label: 'Gacha Roll',
href: '/gacha-roll',
icon: <ReloadOutlined className="text-[20px]" />,
},
{
label: 'Validasi Transaksi',
href: '/transactions',
icon: <AuditOutlined className="text-[20px]" />,
},
{
label: 'Data Pengiriman Hadiah',
href: '/prizes',
icon: <InboxOutlined className="text-[20px]" />,
},
], ],
}, },
{ {
label: 'CMS', label: 'CMS',
icon: <BookOutlined className="text-[20px]" />, icon: BookOpen,
children: [ children: [
{ { label: 'Events', href: '/cms-events', icon: Calendar },
label: 'Events', { label: 'Testimonials', href: '/cms-testimonials', icon: MessageCircle },
href: '/cms-events',
icon: <CalendarOutlined className="text-[20px]" />,
},
{
label: 'Testimonials',
href: '/cms-testimonials',
icon: <MessageOutlined className="text-[20px]" />,
},
], ],
}, },
{
label: 'Permissions',
href: '/permissions',
icon: <UserSwitchOutlined className="text-[20px]" />,
},
{
label: 'Roles',
href: '/roles',
icon: <UsergroupAddOutlined className="text-[20px]" />,
},
{
label: 'Data Akun',
href: '/accounts',
icon: <UserOutlined className="text-[20px]" />,
},
]; ];
interface SidebarProps { const FLAT_MENUS: MenuLink[] = [
isOpen?: boolean; { label: 'Permissions', href: '/permissions', icon: ShieldCheck },
onClose?: () => void; { label: 'Roles', href: '/roles', icon: KeyRound },
} { label: 'Data Akun', href: '/accounts', icon: User },
];
export const BackofficeSidebar: FC<SidebarProps> = ({ const isMenuGroup = (item: MenuItem): item is MenuGroup =>
isOpen = false, (item as MenuGroup).children !== undefined;
onClose,
}): ReactElement => { export const BackofficeSidebar: FC = (): ReactElement => {
const { signOut } = useSession();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); 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 [openGroups, setOpenGroups] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {}; const initial: Record<string, boolean> = {};
MENUS.forEach((menu) => { MENUS.forEach((menu) => {
if (menu.children?.some((child) => child.href && location.pathname.includes(child.href))) { if (
isMenuGroup(menu) &&
menu.children.some((child) => isActive(child.href))
) {
initial[menu.label] = true; initial[menu.label] = true;
} }
}); });
return initial; return initial;
}); });
const isActive = (path: string) => {
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin')
return false;
return location.pathname.includes(path);
};
const toggleGroup = (groupLabel: string) => { const toggleGroup = (label: string) =>
setOpenGroups((prev) => ({ ...prev, [groupLabel]: !prev[groupLabel] })); setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] }));
};
const sidebarContent = ( return (
<div className="w-[280px] bg-white h-svh py-10 lg:py-[60px] px-7 shadow-xl flex flex-col justify-between"> <Sidebar collapsible="offcanvas" variant="inset">
<div className="flex flex-col gap-10 lg:gap-20 justify-between items-center"> <SidebarHeader>
<div className="flex justify-around lg:justify-center items-center w-full"> <div className="flex items-center justify-center px-2 py-3">
<img <img
src="/logos/simple.svg" src="/logos/simple.svg"
alt="IMPHNEN Logo" alt="IMPHNEN Logo"
className="w-[150px]" className="h-10 w-auto"
/> />
{onClose && (
<button
onClick={onClose}
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
aria-label="Close sidebar"
>
<svg
className="w-5 h-5 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div> </div>
</SidebarHeader>
<nav className="flex flex-col gap-4 w-full h-[calc(100svh-20rem)] overflow-y-auto"> <SidebarContent>
<For data={MENUS}> <SidebarGroup>
{(menu) => <SidebarGroupLabel>Navigation</SidebarGroupLabel>
menu.children && menu.children.length > 0 ? ( <SidebarGroupContent>
<div key={menu.label} className="w-full"> <SidebarMenu>
<button {MENUS.map((menu) => {
type="button" const GroupIcon = menu.icon;
onClick={() => toggleGroup(menu.label)} const open = !!openGroups[menu.label];
className={cn( const groupHasActive =
'flex items-center justify-between w-full gap-3 px-2 py-2.5 rounded-md cursor-pointer', isMenuGroup(menu) &&
openGroups[menu.label] menu.children.some((c) => isActive(c.href));
? 'bg-primary-400 hover:bg-primary-500 text-white' return (
: 'text-gray-700 hover:bg-gray-100' <SidebarMenuItem key={menu.label}>
)} <SidebarMenuButton
> onClick={() => toggleGroup(menu.label)}
<div className="flex items-center gap-3"> isActive={groupHasActive && !open}
{menu.icon} className="justify-between"
<span className="text-p3 font-medium">{menu.label}</span> >
</div> <span className="flex items-center gap-2">
<span className="text-label2"> <GroupIcon className="size-4" />
{openGroups[menu.label] ? ( <span>{menu.label}</span>
<DownOutlined className="text-label1" /> </span>
{open ? (
<ChevronDown className="size-3.5 opacity-60" />
) : ( ) : (
<RightOutlined className="text-label1" /> <ChevronRight className="size-3.5 opacity-60" />
)} )}
</span> </SidebarMenuButton>
</button> {isMenuGroup(menu) && open && (
<SidebarMenuSub>
{openGroups[menu.label] && ( {menu.children.map((child) => {
<div className="mt-2 ml-6 flex flex-col gap-2"> const ChildIcon = child.icon;
{menu.children.map((child) => ( return (
<button <SidebarMenuSubItem key={child.href}>
type="button" <SidebarMenuSubButton
key={child.href} asChild
onClick={() => { navigate({ to: child.href as string }); onClose?.(); }} isActive={isActive(child.href)}
className={cn( >
'flex items-center gap-3 px-2 py-2.5 rounded-md cursor-pointer text-left w-full', <button
isActive(child.href) type="button"
? 'bg-primary-100 text-primary-700 hover:bg-primary-200' onClick={() =>
: 'text-gray-700 hover:bg-gray-100' navigate({ to: child.href })
)} }
> className={cn(
{child.icon} 'w-full text-left',
<span className="text-label1 font-medium"> )}
{child.label} >
</span> <ChildIcon className="size-4" />
</button> <span>{child.label}</span>
))} </button>
</div> </SidebarMenuSubButton>
)} </SidebarMenuSubItem>
</div> );
) : ( })}
<button </SidebarMenuSub>
type="button" )}
key={menu.href ?? menu.label} </SidebarMenuItem>
onClick={() => { if (menu.href) { navigate({ to: menu.href as string }); onClose?.(); } }} );
className={cn( })}
'flex items-center justify-items-start gap-3 px-2 py-2.5 cursor-pointer text-left w-full', </SidebarMenu>
menu.href && isActive(menu.href) </SidebarGroupContent>
? 'bg-primary-500 text-white rounded-md' </SidebarGroup>
: 'text-gray-700 hover:bg-gray-100' <SidebarGroup>
)} <SidebarGroupLabel>System</SidebarGroupLabel>
> <SidebarGroupContent>
{menu.icon} <SidebarMenu>
<span className="text-p3 font-medium">{menu.label}</span> {FLAT_MENUS.map((menu) => {
</button> const Icon = menu.icon;
) return (
} <SidebarMenuItem key={menu.href}>
</For> <SidebarMenuButton
</nav> isActive={isActive(menu.href)}
</div> onClick={() => navigate({ to: menu.href })}
>
<div className="w-full"> <Icon className="size-4" />
<hr className="mb-5 border-primary-200" /> <span>{menu.label}</span>
<Button </SidebarMenuButton>
onClick={() => { signOut(); navigate({ to: '/auth/login' as string }); }} </SidebarMenuItem>
variant="text" );
className="items-start justify-start gap-3 px-2 py-2.5 text-gray-700 hover:text-red-500 transition-colors w-full" })}
> </SidebarMenu>
<LogoutOutlined className="text-p3" /> </SidebarGroupContent>
<span className="text-p3 font-medium">Log Out</span> </SidebarGroup>
</Button> </SidebarContent>
</div> </Sidebar>
</div>
);
return (
<>
<div className="hidden lg:block sticky top-0 h-screen overflow-y-auto shadow">
{sidebarContent}
</div>
{isOpen && (
<div className="lg:hidden fixed inset-0 z-50">
<div
className="fixed inset-0 bg-black/50 transition-opacity"
onClick={onClose}
/>
<div className="fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out">
{sidebarContent}
</div>
</div>
)}
</>
); );
}; };
+40
View File
@@ -1,5 +1,6 @@
@import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap');
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css';
@source "../../../libs/ui/**/*.{ts,tsx}"; @source "../../../libs/ui/**/*.{ts,tsx}";
@theme { @theme {
@@ -96,6 +97,45 @@
/* ~10px */ /* ~10px */
--text-label3: 0.677rem; --text-label3: 0.677rem;
/* ~8px */ /* ~8px */
/* shadcn/ui semantic tokens — aliased to existing palette (keep colors consistent) */
--color-background: #ffffff;
--color-foreground: #3d3d3d;
--color-card: #ffffff;
--color-card-foreground: #3d3d3d;
--color-popover: #ffffff;
--color-popover-foreground: #3d3d3d;
--color-primary: #23a1eb;
--color-primary-foreground: #ffffff;
--color-secondary: #e7e7e7;
--color-secondary-foreground: #3d3d3d;
--color-muted: #f6f6f6;
--color-muted-foreground: #6d6d6d;
--color-accent: #e1f0fd;
--color-accent-foreground: #085f9c;
--color-destructive: #ff5242;
--color-destructive-foreground: #ffffff;
--color-border: #d1d1d1;
--color-input: #d1d1d1;
--color-ring: #3eb0f2;
--color-sidebar: #ffffff;
--color-sidebar-foreground: #4f4f4f;
--color-sidebar-primary: #23a1eb;
--color-sidebar-primary-foreground: #ffffff;
--color-sidebar-accent: #f0f8ff;
--color-sidebar-accent-foreground: #085f9c;
--color-sidebar-border: #e7e7e7;
--color-sidebar-ring: #3eb0f2;
--color-chart-1: #23a1eb;
--color-chart-2: #35ba43;
--color-chart-3: #04acf3;
--color-chart-4: #ffed27;
--color-chart-5: #ff5242;
--radius: 8px;
--font-sans: 'Bai Jamjuree', sans-serif;
} }
@layer base { @layer base {
+17 -52
View File
@@ -1,63 +1,28 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router' import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
import { SessionToken } from '@imphnen-frontend-service/service' import { SessionToken } from '@imphnen-frontend-service/service';
import { useState } from 'react' import {
import { BackofficeSidebar } from '../components/sidebar' SidebarInset,
SidebarProvider,
} from '@imphnen-frontend-service/ui/atoms';
import { BackofficeSidebar } from '../components/sidebar';
export const Route = createFileRoute('/_authenticated')({ export const Route = createFileRoute('/_authenticated')({
beforeLoad: () => { beforeLoad: () => {
const session = SessionToken.get() const session = SessionToken.get();
if (!session?.token?.access_token) { if (!session?.token?.access_token) {
throw redirect({ to: '/auth/login' }) throw redirect({ to: '/auth/login' });
} }
}, },
component: AuthenticatedLayout, component: AuthenticatedLayout,
}) });
function AuthenticatedLayout() { function AuthenticatedLayout() {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
return ( return (
<div className="bg-primary-50 min-h-screen flex justify-center"> <SidebarProvider>
<div className="bg-primary-50 min-h-screen w-full flex"> <BackofficeSidebar />
<BackofficeSidebar <SidebarInset>
isOpen={mobileSidebarOpen} <Outlet />
onClose={() => setMobileSidebarOpen(false)} </SidebarInset>
/> </SidebarProvider>
<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>
)
} }
@@ -1,5 +1,4 @@
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts" import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"
import { PieLabelProps } from "recharts/types/polar/Pie"
type ChartProps = { type ChartProps = {
name: string name: string
@@ -14,14 +13,20 @@ const chartData: ChartProps[] = [
] ]
const RADIAN = Math.PI / 180; const RADIAN = Math.PI / 180;
const renderCustomizedLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent }: PieLabelProps) => { const renderCustomizedLabel = (props: any) => {
const cx = props.cx ?? 0
const cy = props.cy ?? 0
const midAngle = props.midAngle ?? 0
const innerRadius = props.innerRadius ?? 0
const outerRadius = props.outerRadius ?? 0
const percent = props.percent ?? 0
const radius = innerRadius + (outerRadius - innerRadius) * 0.5; const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-(midAngle ?? 0) * RADIAN); const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-(midAngle ?? 0) * RADIAN); const y = cy + radius * Math.sin(-midAngle * RADIAN);
return ( return (
<text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central"> <text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central">
{`${((percent ?? 1) * 100).toFixed(0)}%`} {`${(percent * 100).toFixed(0)}%`}
</text> </text>
); );
}; };
@@ -1,4 +1,4 @@
import { Button, Input, Select, ToggleInput } from "@imphnen-frontend-service/ui/atoms" import { Button, Input, NativeSelect as Select, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils" import { cn } from "@imphnen-frontend-service/utils"
import { FC } from "react" import { FC } from "react"
@@ -1,4 +1,4 @@
import { Button, Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms" import { Button, Input, NativeSelect as Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils" import { cn } from "@imphnen-frontend-service/utils"
import { FC } from "react" import { FC } from "react"
@@ -1,13 +1,23 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react' import * as React from 'react';
import { Fragment, useState } from 'react' import { Filter as FilterIcon, Search, Pencil } from 'lucide-react';
import { import {
FilterOutlined, Button,
SearchOutlined, Card,
EditOutlined, CardContent,
} from '@ant-design/icons' CardHeader,
import { Button, Input } from '@imphnen-frontend-service/ui/atoms' Checkbox,
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms' Input,
Popover,
PopoverContent,
PopoverTrigger,
Badge,
} from '@imphnen-frontend-service/ui/atoms';
import {
DataTable,
Filter,
BackofficeWrapper,
} from '@imphnen-frontend-service/ui/organisms';
import { import {
ColumnDef, ColumnDef,
getCoreRowModel, getCoreRowModel,
@@ -15,107 +25,96 @@ import {
PaginationState, PaginationState,
useReactTable, useReactTable,
RowSelectionState, RowSelectionState,
} from '@tanstack/react-table' } from '@tanstack/react-table';
import { import {
useUserList, useUserList,
TUsersListItem, TUsersListItem,
} from '@imphnen-frontend-service/service' } from '@imphnen-frontend-service/service';
export const Route = createFileRoute('/_authenticated/accounts')({ export const Route = createFileRoute('/_authenticated/accounts')({
component: AccountsPage, component: AccountsPage,
}) });
function AccountsPage() { function AccountsPage() {
const navigate = useNavigate() const navigate = useNavigate();
const [search, setSearch] = useState('') const [search, setSearch] = React.useState('');
const [pagination, setPagination] = React.useState<PaginationState>({ const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 10,
}) });
const [rowSelection, setRowSelection] =
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({}) React.useState<RowSelectionState>({});
const [showFilter, setShowFilter] = useState(false) const [showFilter, setShowFilter] = React.useState(false);
const { data: usersData, isLoading } = useUserList({ const { data: usersData, isLoading } = useUserList({
search, search,
page: pagination.pageIndex + 1, page: pagination.pageIndex + 1,
per_page: pagination.pageSize, per_page: pagination.pageSize,
}) });
const users: TUsersListItem[] = usersData?.data ?? [] const users: TUsersListItem[] = usersData?.data ?? [];
const totalItems = usersData?.meta?.total ?? users.length const totalItems = usersData?.meta?.total ?? users.length;
const columns: ColumnDef<TUsersListItem>[] = [ const columns: ColumnDef<TUsersListItem>[] = [
{ {
id: 'select', id: 'select',
header: ({ table }) => ( header: ({ table }) => (
<input <Checkbox
type="checkbox" checked={
className="rounded" table.getIsAllRowsSelected()
checked={table.getIsAllRowsSelected()} ? true
onChange={table.getToggleAllRowsSelectedHandler()} : table.getIsSomeRowsSelected()
? 'indeterminate'
: false
}
onCheckedChange={(v) =>
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
}
aria-label="Select all"
/> />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<input <Checkbox
type="checkbox"
className="rounded"
checked={row.getIsSelected()} checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()} onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="Select row"
/> />
), ),
}, },
{ { header: 'No', accessorKey: 'id' },
header: 'No', { header: 'Nama Lengkap', accessorKey: 'fullname' },
accessorKey: 'id', { header: 'Email', accessorKey: 'email' },
}, { header: 'Role', accessorKey: 'role' },
{
header: 'Nama Lengkap',
accessorKey: 'fullname',
},
{
header: 'Email',
accessorKey: 'email',
},
{
header: 'Role',
accessorKey: 'role',
},
{ {
header: 'Status', header: 'Status',
accessorKey: 'is_active', accessorKey: 'is_active',
cell: ({ row }) => ( cell: ({ row }) => (
<span className={row.original.is_active ? 'text-success-500' : 'text-danger-500'}> <Badge variant={row.original.is_active ? 'success' : 'destructive'}>
{row.original.is_active ? 'Aktif' : 'Tidak Aktif'} {row.original.is_active ? 'Aktif' : 'Tidak Aktif'}
</span> </Badge>
), ),
}, },
{ {
header: 'Action', header: 'Action',
cell: ({ row }) => ( cell: ({ row }) => (
<Button <Button
variant="primary" variant="secondary"
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
navigate({ to: '/accounts/$id', params: { id: row.original.id } }) navigate({ to: '/accounts/$id', params: { id: row.original.id } });
}} }}
className="flex items-center gap-2"
> >
<EditOutlined /> Edit <Pencil className="size-3.5" /> Edit
</Button> </Button>
), ),
}, },
] ];
const table = useReactTable({ const table = useReactTable({
data: users, data: users,
columns, columns,
state: { state: { pagination, rowSelection },
pagination,
rowSelection,
},
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
@@ -123,52 +122,65 @@ function AccountsPage() {
onPaginationChange: setPagination, onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize), pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true, manualPagination: true,
}) });
return ( return (
<Fragment> <BackofficeWrapper
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8"> title="Data Akun"
<header className="bg-white py-4 px-8 rounded-lg shadow p-4"> description="Kelola akun pengguna yang terdaftar"
<h1 className="text-p2 font-semibold">Data Akun</h1> >
</header> <Card>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md"> <CardHeader>
<div className="flex justify-between items-center gap-8 mb-2"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full"> <div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder="Cari berdasarkan nama lengkap, email" placeholder="Cari nama lengkap atau email…"
className="pl-12 w-full max-h-full" className="pl-9"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div 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> </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> </div>
</CardHeader>
<CardContent>
{isLoading ? ( {isLoading ? (
<div className="text-center py-8 text-neutral-400">Loading...</div> <div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : ( ) : (
<DataTable data={users} columns={columns} table={table} /> <DataTable
data={users}
columns={columns}
table={table}
manualPagination
pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)} )}
</section> </CardContent>
</main> </Card>
</Fragment> </BackofficeWrapper>
) );
} }
@@ -1,110 +1,122 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useEffect, useState } from 'react' import * as React from 'react';
import { toast } from 'sonner' import { toast } from 'sonner';
import { ArrowLeftOutlined } from '@ant-design/icons' import { ArrowLeft } from 'lucide-react';
import { Button } from '@imphnen-frontend-service/ui/atoms' import {
import { InputField } from '@imphnen-frontend-service/ui/molecules' Button,
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@imphnen-frontend-service/ui/atoms';
import { InputField } from '@imphnen-frontend-service/ui/molecules';
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import { import {
useUserList, useUserList,
useUpdateUserById, useUpdateUserById,
} from '@imphnen-frontend-service/service' } from '@imphnen-frontend-service/service';
export const Route = createFileRoute('/_authenticated/accounts_/$id')({ export const Route = createFileRoute('/_authenticated/accounts_/$id')({
component: AccountsEditPage, component: AccountsEditPage,
}) });
function AccountsEditPage() { function AccountsEditPage() {
const { id } = Route.useParams() const { id } = Route.useParams();
const navigate = useNavigate() const navigate = useNavigate();
const updateUser = useUpdateUserById() const updateUser = useUpdateUserById();
const { data: usersData, isLoading } = useUserList({ search: '', per_page: 100 }) const { data: usersData, isLoading } = useUserList({
const user = usersData?.data?.find((u) => u.id === id) search: '',
per_page: 100,
});
const user = usersData?.data?.find((u) => u.id === id);
const [fullName, setFullName] = useState('') const [fullName, setFullName] = React.useState('');
const [email, setEmail] = useState('') const [email, setEmail] = React.useState('');
useEffect(() => { React.useEffect(() => {
if (user) { if (user) {
setFullName(user.fullname) setFullName(user.fullname);
setEmail(user.email) setEmail(user.email);
} }
}, [user]) }, [user]);
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
await updateUser.mutateAsync({ id, data: { fullname: fullName, email } }) await updateUser.mutateAsync({
toast.success('Data akun berhasil diperbarui') id,
navigate({ to: '/accounts' }) data: { fullname: fullName, email },
});
toast.success('Data akun berhasil diperbarui');
navigate({ to: '/accounts' });
} catch (error) { } catch (error) {
console.log(error) console.log(error);
toast.error('Data akun gagal diperbarui') toast.error('Data akun gagal diperbarui');
} }
} };
if (isLoading) {
return (
<main className="w-full px-[48px] py-[40px]">
<div className="text-center py-8 text-neutral-400">Loading...</div>
</main>
)
}
return ( return (
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8"> <BackofficeWrapper title="Edit Data Akun">
<div className="max-w-2xl mx-auto w-full"> <div className="mx-auto w-full max-w-2xl">
<div className="flex items-center gap-3 mb-6"> <Button
<button variant="text"
onClick={() => navigate({ to: '/accounts' })} size="sm"
className="text-primary-500 hover:text-primary-600" onClick={() => navigate({ to: '/accounts' })}
> className="mb-4 -ml-2"
<ArrowLeftOutlined className="text-[20px]" /> >
</button> <ArrowLeft className="size-4" />
<h1 className="text-2xl font-bold text-gray-900">Edit Data Akun</h1> Kembali
</div> </Button>
<Card>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6"> <CardHeader>
<div className="flex flex-col gap-6"> <CardTitle>Edit Data Akun</CardTitle>
<InputField </CardHeader>
label="Nama Lengkap" <CardContent>
type="text" {isLoading ? (
placeholder="Masukkan Nama Lengkap" <div className="py-10 text-center text-sm text-muted-foreground">
value={fullName} Memuat data…
onChange={(e) => setFullName(e.target.value)} </div>
size="lg" ) : (
className="w-full" <div className="flex flex-col gap-4">
/> <InputField
<InputField label="Nama Lengkap"
label="Email" type="text"
type="text" placeholder="Masukkan nama lengkap"
placeholder="Masukkan Email" value={fullName}
value={email} onChange={(e) => setFullName(e.target.value)}
onChange={(e) => setEmail(e.target.value)} size="md"
size="lg" />
className="w-full" <InputField
/> label="Email"
type="text"
<div className="flex gap-3 pt-4"> placeholder="Masukkan email"
<Button value={email}
variant="primary" onChange={(e) => setEmail(e.target.value)}
size="lg" size="md"
className="w-full" />
onClick={handleSubmit} </div>
> )}
Perbarui Data </CardContent>
</Button> <CardFooter className="justify-end gap-2">
<Button <Button
variant="bordered" variant="secondary"
size="lg" size="md"
className="w-full" onClick={() => navigate({ to: '/accounts' })}
onClick={() => navigate({ to: '/accounts' })} >
> Batal
Batal </Button>
</Button> <Button
</div> variant="primary"
</div> size="md"
</div> onClick={handleSubmit}
disabled={updateUser.isPending || isLoading}
>
{updateUser.isPending ? 'Menyimpan…' : 'Perbarui Data'}
</Button>
</CardFooter>
</Card>
</div> </div>
</main> </BackofficeWrapper>
) );
} }
@@ -1,13 +1,27 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { Fragment, useState } from 'react' import * as React from 'react';
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
import { import {
SearchOutlined, AlertDialog,
EditOutlined, AlertDialogAction,
DeleteOutlined, AlertDialogCancel,
PlusOutlined, AlertDialogContent,
} from '@ant-design/icons' AlertDialogDescription,
import { Button, Input } from '@imphnen-frontend-service/ui/atoms' AlertDialogFooter,
import { DataTable } from '@imphnen-frontend-service/ui/organisms' AlertDialogHeader,
AlertDialogTitle,
Badge,
Button,
Card,
CardContent,
CardHeader,
Checkbox,
Input,
} from '@imphnen-frontend-service/ui/atoms';
import {
DataTable,
BackofficeWrapper,
} from '@imphnen-frontend-service/ui/organisms';
import { import {
ColumnDef, ColumnDef,
getCoreRowModel, getCoreRowModel,
@@ -15,76 +29,77 @@ import {
PaginationState, PaginationState,
RowSelectionState, RowSelectionState,
useReactTable, useReactTable,
} from '@tanstack/react-table' } from '@tanstack/react-table';
import { import {
useEventList, useEventList,
useDeleteEvent, useDeleteEvent,
TEventsListItem, TEventsListItem,
} from '@imphnen-frontend-service/service' } from '@imphnen-frontend-service/service';
import React from 'react' import { toast } from 'sonner';
import { toast } from 'sonner'
export const Route = createFileRoute('/_authenticated/cms-events')({ export const Route = createFileRoute('/_authenticated/cms-events')({
component: CmsEventsPage, component: CmsEventsPage,
}) });
function CmsEventsPage() { function CmsEventsPage() {
const navigate = useNavigate() const navigate = useNavigate();
const [search, setSearch] = useState('') const [search, setSearch] = React.useState('');
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = React.useState<string | null>(null);
const [pagination, setPagination] = React.useState<PaginationState>({ const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 10,
}) });
const [rowSelection, setRowSelection] =
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({}) React.useState<RowSelectionState>({});
const { data: eventsData, isLoading } = useEventList({ const { data: eventsData, isLoading } = useEventList({
search, search,
page: pagination.pageIndex + 1, page: pagination.pageIndex + 1,
per_page: pagination.pageSize, per_page: pagination.pageSize,
}) });
const deleteEvent = useDeleteEvent() const deleteEvent = useDeleteEvent();
const events: TEventsListItem[] = eventsData?.data ?? [] const events: TEventsListItem[] = eventsData?.data ?? [];
const totalItems = eventsData?.meta?.total ?? events.length const totalItems = eventsData?.meta?.total ?? events.length;
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { try {
await deleteEvent.mutateAsync(id) await deleteEvent.mutateAsync(id);
toast.success('Data event berhasil dihapus') toast.success('Data event berhasil dihapus');
setDeleteId(null) setDeleteId(null);
} catch (error) { } catch (error) {
console.log(error) console.log(error);
toast.error('Data event gagal dihapus') toast.error('Data event gagal dihapus');
} }
} };
const columns: ColumnDef<TEventsListItem>[] = [ const columns: ColumnDef<TEventsListItem>[] = [
{ {
id: 'select', id: 'select',
header: ({ table }) => ( header: ({ table }) => (
<input <Checkbox
type="checkbox" checked={
className="rounded" table.getIsAllRowsSelected()
checked={table.getIsAllRowsSelected()} ? true
onChange={table.getToggleAllRowsSelectedHandler()} : table.getIsSomeRowsSelected()
? 'indeterminate'
: false
}
onCheckedChange={(v) =>
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
}
aria-label="Select all"
/> />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<input <Checkbox
type="checkbox"
className="rounded"
checked={row.getIsSelected()} checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()} onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="Select row"
/> />
), ),
}, },
{ { header: 'Name', accessorKey: 'name' },
header: 'Name',
accessorKey: 'name',
},
{ {
header: 'Location', header: 'Location',
accessorKey: 'location', accessorKey: 'location',
@@ -112,81 +127,49 @@ function CmsEventsPage() {
header: 'Online', header: 'Online',
accessorKey: 'is_online', accessorKey: 'is_online',
cell: ({ row }) => ( cell: ({ row }) => (
<span <Badge variant={row.original.is_online ? 'success' : 'secondary'}>
className={`px-2 py-1 rounded-full text-label2 font-medium ${
row.original.is_online
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{row.original.is_online ? 'Online' : 'Offline'} {row.original.is_online ? 'Online' : 'Offline'}
</span> </Badge>
), ),
}, },
{ {
header: 'Action', header: 'Action',
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex gap-[8px]"> <div className="flex items-center gap-2">
<Button <Button
variant="primary" variant="secondary"
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
navigate({ to: '/cms-events/$id', params: { id: row.original.id } }) 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> </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> </div>
), ),
}, },
] ];
const table = useReactTable({ const table = useReactTable({
data: events, data: events,
columns, columns,
state: { state: { pagination, rowSelection },
pagination,
rowSelection,
},
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
@@ -194,53 +177,74 @@ function CmsEventsPage() {
onPaginationChange: setPagination, onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize), pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true, manualPagination: true,
}) });
return ( return (
<Fragment> <BackofficeWrapper title="CMS Events" description="Kelola event komunitas">
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8"> <Card>
<header className="bg-white py-4 px-8 rounded-lg shadow p-4"> <CardHeader>
<h1 className="text-p2 font-semibold">CMS Events</h1> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
</header> <div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<Input <Input
placeholder="Cari berdasarkan nama event" placeholder="Cari nama event…"
className="pl-12 w-full max-h-full" className="pl-9"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex gap-3 text-nowrap"
onClick={() => navigate({ to: '/cms-events/create' })}
>
<PlusOutlined />
Tambah Event
</Button>
</div> </div>
<Button
onClick={() => navigate({ to: '/cms-events/create' })}
size="md"
>
<Plus className="size-4" />
Tambah Event
</Button>
</div> </div>
</CardHeader>
<CardContent>
{isLoading ? ( {isLoading ? (
<div className="text-center py-8 text-neutral-400">Loading...</div> <div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : ( ) : (
<DataTable <DataTable
data={events} data={events}
columns={columns} columns={columns}
pageSize={9}
table={table} table={table}
manualPagination
pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/> />
)} )}
</section> </CardContent>
</main> </Card>
</Fragment>
) <AlertDialog
open={!!deleteId}
onOpenChange={(o) => !o && setDeleteId(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hapus event ini?</AlertDialogTitle>
<AlertDialogDescription>
Tindakan ini tidak dapat dibatalkan. Event akan dihapus permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Batal</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteId && handleDelete(deleteId)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Hapus
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</BackofficeWrapper>
);
} }
@@ -1,13 +1,26 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { Fragment, useState } from 'react' import * as React from 'react';
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
import { import {
SearchOutlined, AlertDialog,
EditOutlined, AlertDialogAction,
DeleteOutlined, AlertDialogCancel,
PlusOutlined, AlertDialogContent,
} from '@ant-design/icons' AlertDialogDescription,
import { Button, Input } from '@imphnen-frontend-service/ui/atoms' AlertDialogFooter,
import { DataTable } from '@imphnen-frontend-service/ui/organisms' AlertDialogHeader,
AlertDialogTitle,
Button,
Card,
CardContent,
CardHeader,
Checkbox,
Input,
} from '@imphnen-frontend-service/ui/atoms';
import {
DataTable,
BackofficeWrapper,
} from '@imphnen-frontend-service/ui/organisms';
import { import {
ColumnDef, ColumnDef,
getCoreRowModel, getCoreRowModel,
@@ -15,86 +28,84 @@ import {
PaginationState, PaginationState,
RowSelectionState, RowSelectionState,
useReactTable, useReactTable,
} from '@tanstack/react-table' } from '@tanstack/react-table';
import { import {
useTestimonialList, useTestimonialList,
useDeleteTestimonial, useDeleteTestimonial,
TTestimonialsListItem, TTestimonialsListItem,
} from '@imphnen-frontend-service/service' } from '@imphnen-frontend-service/service';
import React from 'react' import { toast } from 'sonner';
import { toast } from 'sonner'
export const Route = createFileRoute('/_authenticated/cms-testimonials')({ export const Route = createFileRoute('/_authenticated/cms-testimonials')({
component: CmsTestimonialsPage, component: CmsTestimonialsPage,
}) });
function CmsTestimonialsPage() { function CmsTestimonialsPage() {
const navigate = useNavigate() const navigate = useNavigate();
const [search, setSearch] = useState('') const [search, setSearch] = React.useState('');
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = React.useState<string | null>(null);
const [pagination, setPagination] = React.useState<PaginationState>({ const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 10,
}) });
const [rowSelection, setRowSelection] =
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({}) React.useState<RowSelectionState>({});
const { data: testimonialsData, isLoading } = useTestimonialList({ const { data: testimonialsData, isLoading } = useTestimonialList({
search, search,
page: pagination.pageIndex + 1, page: pagination.pageIndex + 1,
per_page: pagination.pageSize, per_page: pagination.pageSize,
}) });
const deleteTestimonial = useDeleteTestimonial() const deleteTestimonial = useDeleteTestimonial();
const testimonials: TTestimonialsListItem[] = testimonialsData?.data ?? [] const testimonials: TTestimonialsListItem[] = testimonialsData?.data ?? [];
const totalItems = testimonialsData?.meta?.total ?? testimonials.length const totalItems = testimonialsData?.meta?.total ?? testimonials.length;
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { try {
await deleteTestimonial.mutateAsync(id) await deleteTestimonial.mutateAsync(id);
toast.success('Data testimonial berhasil dihapus') toast.success('Data testimonial berhasil dihapus');
setDeleteId(null) setDeleteId(null);
} catch (error) { } catch (error) {
console.log(error) console.log(error);
toast.error('Data testimonial gagal dihapus') toast.error('Data testimonial gagal dihapus');
} }
} };
const columns: ColumnDef<TTestimonialsListItem>[] = [ const columns: ColumnDef<TTestimonialsListItem>[] = [
{ {
id: 'select', id: 'select',
header: ({ table }) => ( header: ({ table }) => (
<input <Checkbox
type="checkbox" checked={
className="rounded" table.getIsAllRowsSelected()
checked={table.getIsAllRowsSelected()} ? true
onChange={table.getToggleAllRowsSelectedHandler()} : table.getIsSomeRowsSelected()
? 'indeterminate'
: false
}
onCheckedChange={(v) =>
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
}
/> />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<input <Checkbox
type="checkbox"
className="rounded"
checked={row.getIsSelected()} checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()} onCheckedChange={(v) => row.toggleSelected(!!v)}
/> />
), ),
}, },
{ { header: 'User', accessorKey: 'user_fullname' },
header: 'User', { header: 'Role', accessorKey: 'role' },
accessorKey: 'user_fullname',
},
{
header: 'Role',
accessorKey: 'role',
},
{ {
header: 'Content', header: 'Content',
accessorKey: 'content', accessorKey: 'content',
cell: ({ row }) => { cell: ({ row }) => {
const content = row.original.content const content = row.original.content;
return content.length > 80 ? `${content.substring(0, 80)}...` : content return content.length > 80
? `${content.substring(0, 80)}…`
: content;
}, },
}, },
{ {
@@ -110,67 +121,41 @@ function CmsTestimonialsPage() {
{ {
header: 'Action', header: 'Action',
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex gap-[8px]"> <div className="flex items-center gap-2">
<Button <Button
variant="primary" variant="secondary"
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
navigate({ to: '/cms-testimonials/$id', params: { id: row.original.id } }) 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> </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> </div>
), ),
}, },
] ];
const table = useReactTable({ const table = useReactTable({
data: testimonials, data: testimonials,
columns, columns,
state: { state: { pagination, rowSelection },
pagination,
rowSelection,
},
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
@@ -178,53 +163,78 @@ function CmsTestimonialsPage() {
onPaginationChange: setPagination, onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize), pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true, manualPagination: true,
}) });
return ( return (
<Fragment> <BackofficeWrapper
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8"> title="CMS Testimonials"
<header className="bg-white py-4 px-8 rounded-lg shadow p-4"> description="Kelola testimonial pengguna"
<h1 className="text-p2 font-semibold">CMS Testimonials</h1> >
</header> <Card>
<CardHeader>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex justify-between items-center gap-8 mb-2"> <div className="relative w-full sm:max-w-sm">
<div className="relative w-full"> <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder="Cari berdasarkan nama user" placeholder="Cari nama user…"
className="pl-12 w-full max-h-full" className="pl-9"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex gap-3 text-nowrap"
onClick={() => navigate({ to: '/cms-testimonials/create' })}
>
<PlusOutlined />
Tambah Testimonial
</Button>
</div> </div>
<Button
onClick={() => navigate({ to: '/cms-testimonials/create' })}
size="md"
>
<Plus className="size-4" />
Tambah Testimonial
</Button>
</div> </div>
</CardHeader>
<CardContent>
{isLoading ? ( {isLoading ? (
<div className="text-center py-8 text-neutral-400">Loading...</div> <div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : ( ) : (
<DataTable <DataTable
data={testimonials} data={testimonials}
columns={columns} columns={columns}
pageSize={9}
table={table} table={table}
manualPagination
pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/> />
)} )}
</section> </CardContent>
</main> </Card>
</Fragment>
) <AlertDialog
open={!!deleteId}
onOpenChange={(o) => !o && setDeleteId(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hapus testimonial ini?</AlertDialogTitle>
<AlertDialogDescription>
Tindakan ini tidak dapat dibatalkan. Testimonial akan dihapus
permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Batal</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteId && handleDelete(deleteId)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Hapus
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</BackofficeWrapper>
);
} }
@@ -1,152 +1,212 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router';
import { Button } from '@imphnen-frontend-service/ui/atoms' import * as React from 'react';
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms' import { Users, UserCog, CalendarClock, Activity, CircleCheck } from 'lucide-react';
import { For } from '@imphnen-frontend-service/utils' import {
import { ReactElement } from 'react' Card,
import { UserGrowthChart } from './_components/dashboard-dimentorin/chart/user-growth' CardContent,
import { SessionStatusChart } from './_components/dashboard-dimentorin/chart/session-status' CardDescription,
import { useMentorList, useUserList, useMySessions } from '@imphnen-frontend-service/service' CardHeader,
CardTitle,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@imphnen-frontend-service/ui/atoms';
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import { UserGrowthChart } from './_components/dashboard-dimentorin/chart/user-growth';
import { SessionStatusChart } from './_components/dashboard-dimentorin/chart/session-status';
import {
useMentorList,
useUserList,
useMySessions,
} from '@imphnen-frontend-service/service';
export const Route = createFileRoute('/_authenticated/dashboard-dimentorin')({ export const Route = createFileRoute('/_authenticated/dashboard-dimentorin')({
component: DashboardDimentorinPage, component: DashboardDimentorinPage,
}) });
function DashboardDimentorinPage(): ReactElement { type StatCardProps = {
const { data: mentorData } = useMentorList({ per_page: 5, sort_by: 'rating', order: 'desc' }) icon: React.ComponentType<{ className?: string }>;
const { data: userData } = useUserList({ per_page: 1 }) label: string;
const { data: sessionsData } = useMySessions() value: React.ReactNode;
};
const totalMentors = mentorData?.meta?.total ?? 0 function StatCard({ icon: Icon, label, value }: StatCardProps) {
const totalUsers = userData?.meta?.total ?? 0 return (
const totalSessions = sessionsData?.total ?? 0 <Card>
const topMentors = mentorData?.data ?? [] <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 = [ function DashboardDimentorinPage() {
{ label: 'Total Users', value: totalUsers }, const { data: mentorData } = useMentorList({
{ label: 'Total Mentors', value: totalMentors }, per_page: 5,
{ label: 'Total Sessions', value: totalSessions }, sort_by: 'rating',
{ label: 'Active Mentors', value: topMentors.filter((m) => m.status === 'active').length }, order: 'desc',
{ label: 'Completed Sessions', value: sessionsData?.sessions?.filter((s) => s.status === 'completed').length ?? 0 }, });
] 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 ( return (
<BackofficeWrapper title="Dimentorin.dev"> <BackofficeWrapper
<h1 className="text-p1 font-semibold text-neutral-700 mb-5">Overview</h1> 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"> <section className="grid grid-cols-1 gap-4 lg:grid-cols-7">
<div> <Card className="lg:col-span-5">
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200"> <CardHeader>
Overview <CardTitle>User Growth</CardTitle>
</Button> <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"> <section className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<For data={overviewStats}> <Card>
{(stat, index) => ( <CardHeader>
<div key={index} className="bg-white px-6 py-4 rounded-md shadow"> <CardTitle>Top 5 Mentors</CardTitle>
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">{stat.value}</h3> <CardDescription>Mentor dengan rating tertinggi</CardDescription>
<p className="text-neutral-400 text-p3">{stat.label}</p> </CardHeader>
</div> <CardContent>
)} <div className="rounded-md border border-neutral-200">
</For> <Table>
</div> <TableHeader>
</div> <TableRow className="hover:bg-transparent">
<TableHead className="w-[10%]">No.</TableHead>
<div> <TableHead>Nama Lengkap</TableHead>
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200"> <TableHead>Avg Rating</TableHead>
Trends & Analytics </TableRow>
</Button> </TableHeader>
<TableBody>
<div className="grid grid-cols-7 gap-x-5"> {topMentors.length === 0 ? (
<div className="bg-white px-6 py-4 rounded-lg col-span-5"> <TableRow className="hover:bg-transparent">
<div className="flex items-center justify-between mb-7"> <TableCell
<h2 className="font-semibold text-p3 text-neutral-700">User Growth</h2> colSpan={3}
<div></div> className="py-6 text-center text-sm text-muted-foreground"
</div> >
<UserGrowthChart /> Belum ada data
</div> </TableCell>
<div className="bg-white px-6 py-4 rounded-lg col-span-2"> </TableRow>
<h2 className="font-semibold text-p3 text-neutral-700 mb-7">Session Status</h2> ) : (
<SessionStatusChart /> topMentors.slice(0, 5).map((mentor, index) => (
</div> <TableRow key={mentor.id}>
</div> <TableCell>{index + 1}</TableCell>
</div> <TableCell>{mentor.fullname ?? '-'}</TableCell>
<TableCell>
<div className="grid grid-cols-2 gap-x-5"> {mentor.rating?.toFixed(1) ?? '-'}
<div className="bg-white px-7 py-4 rounded-lg"> </TableCell>
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top 5 Mentors</h2> </TableRow>
<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>
)) ))
})()} )}
</tbody> </TableBody>
</table> </Table>
</div> </div>
</div> </CardContent>
</div> </Card>
</div> <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> </BackofficeWrapper>
) );
} }
@@ -1,195 +1,185 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
import { import {
PlusOutlined, Plus,
ReloadOutlined, UsersRound,
UsergroupAddOutlined, UserMinus,
UsergroupDeleteOutlined, UserCog,
UserSwitchOutlined, RefreshCcw,
DeleteOutlined, Pencil,
} from '@ant-design/icons' Trash2,
import { Button } from '@imphnen-frontend-service/ui/atoms' MoreHorizontal,
import { Fragment, useState } from 'react' } from 'lucide-react';
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@imphnen-frontend-service/ui/atoms';
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import { import {
useUserList, useUserList,
useGachaItemList, useGachaItemList,
useDeleteGachaItem, useDeleteGachaItem,
TGachaItemDto, TGachaItemDto,
} from '@imphnen-frontend-service/service' } from '@imphnen-frontend-service/service';
import { toast } from 'sonner' import { toast } from 'sonner';
import { DeleteConfirmDialog } from '../../components/list-helpers';
export const Route = createFileRoute('/_authenticated/dashboard')({ export const Route = createFileRoute('/_authenticated/dashboard')({
component: DashboardPage, component: DashboardPage,
}) });
type StatCardProps = {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: React.ReactNode;
};
function StatCard({ icon: Icon, label, value }: StatCardProps) {
return (
<Card>
<CardContent className="flex items-center gap-4 pt-6">
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary-100 text-primary-600">
<Icon className="size-5" />
</div>
<div className="flex flex-col">
<span className="text-2xl font-semibold leading-tight text-foreground">
{value}
</span>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
</CardContent>
</Card>
);
}
function DashboardPage() { function DashboardPage() {
const navigate = useNavigate() const navigate = useNavigate();
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = React.useState<string | null>(null);
const { data: usersData } = useUserList({ per_page: 1 }) const { data: usersData } = useUserList({ per_page: 1 });
const { data: gachaItemsData } = useGachaItemList({ per_page: 9 }) const { data: gachaItemsData } = useGachaItemList({ per_page: 9 });
const deleteItem = useDeleteGachaItem() const deleteItem = useDeleteGachaItem();
const totalUsers = usersData?.meta?.total ?? 0 const totalUsers = usersData?.meta?.total ?? 0;
const gachaItems: TGachaItemDto[] = gachaItemsData?.data ?? [] const gachaItems: TGachaItemDto[] = gachaItemsData?.data ?? [];
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { try {
await deleteItem.mutateAsync(id) await deleteItem.mutateAsync(id);
toast.success('Item berhasil dihapus') toast.success('Item berhasil dihapus');
setDeleteId(null) setDeleteId(null);
} catch (error) { } catch (error) {
console.log(error) console.log(error);
toast.error('Item gagal dihapus') toast.error('Item gagal dihapus');
} }
} };
return ( return (
<Fragment> <BackofficeWrapper
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8"> title="Gacha Dashboard"
<header className="bg-white py-4 px-8 rounded-lg shadow p-4"> description="Ringkasan statistik & daftar item gacha"
<h1 className="text-p2 font-semibold">Dashboard</h1> >
</header> <section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
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"> <Card>
<div className="w-full flex flex-col gap-[40px]"> <CardHeader>
<section> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-p2 font-medium text-primary-500 mb-8"> <div>
Summary <CardTitle>Gacha Items</CardTitle>
</h2> <CardDescription>
<div className="grid grid-cols-2 gap-4"> Daftar item yang tersedia di gacha
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100"> </CardDescription>
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md"> </div>
<UsergroupAddOutlined className="text-[20px]" /> <Button
</div> size="md"
<div className="flex flex-col gap-1"> onClick={() => navigate({ to: '/dashboard/create' })}
<h3 className="text-p1 font-semibold">{totalUsers}</h3> >
<p className="text-label1 text-neutral-500">Participants</p> <Plus className="size-4" />
</div> Tambah Item
</div> </Button>
<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>
</div> </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 <DeleteConfirmDialog
src="gacha.webp" open={!!deleteId}
alt="" onOpenChange={(o) => !o && setDeleteId(null)}
className="rounded-lg hidden xl:block xl:min-w-[436px] h-auto object-cover" onConfirm={() => deleteId && handleDelete(deleteId)}
/> title="Hapus item gacha ini?"
</div> />
</main> </BackofficeWrapper>
</Fragment> );
)
} }
@@ -1,62 +1,91 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router';
import { SearchOutlined } from '@ant-design/icons' import * as React from 'react';
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms' import { Search, MessageSquare } from 'lucide-react';
import { BackofficeWrapper, DataTable } from '@imphnen-frontend-service/ui/organisms' import {
import { cn, For } from '@imphnen-frontend-service/utils' Badge,
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from '@tanstack/react-table' Button,
import { ReactElement, useState } from 'react' Card,
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service' CardContent,
CardHeader,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@imphnen-frontend-service/ui/atoms';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { cn } from '@imphnen-frontend-service/utils';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service';
import {
SelectAllCheckbox,
RowSelectCheckbox,
} from '../../components/list-helpers';
const TABS = { export const Route = createFileRoute(
MENTORING: 'Mentoring', '/_authenticated/feedback-review-dimentorin'
PLATFORM: 'Platform' )({
} as const
type Tabs = typeof TABS[keyof typeof TABS]
export const Route = createFileRoute('/_authenticated/feedback-review-dimentorin')({
component: FeedbackReviewDimentorinPage, component: FeedbackReviewDimentorinPage,
}) });
function FeedbackReviewDimentorinPage(): ReactElement { function FeedbackReviewDimentorinPage() {
const [activeTab, setActiveTab] = useState<Tabs>(TABS.MENTORING) const [activeTab, setActiveTab] = React.useState<'mentoring' | 'platform'>(
'mentoring'
);
const [ratingFilter, setRatingFilter] = React.useState<string>('all');
const [statusFilter, setStatusFilter] = React.useState<string>('all');
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) const [rowSelection, setRowSelection] =
const [pagination, setPagination] = useState<PaginationState>({ React.useState<RowSelectionState>({});
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 10,
}) });
const { data: sessionsData, isLoading } = useMySessions( const { data: sessionsData, isLoading } = useMySessions(
activeTab === TABS.MENTORING ? { status: 'completed' } : undefined activeTab === 'mentoring' ? { status: 'completed' } : undefined
) );
const sessions: TSessionListItem[] = activeTab === TABS.MENTORING const allSessions: TSessionListItem[] =
? (sessionsData?.sessions ?? []) activeTab === 'mentoring' ? (sessionsData?.sessions ?? []) : [];
: []
const totalItems = activeTab === TABS.MENTORING const sessions = React.useMemo(() => {
? (sessionsData?.total ?? sessions.length) return allSessions.filter((s) => {
: 0 if (statusFilter !== 'all') {
const hasRating = !!s.rating;
if (statusFilter === 'done' && !hasRating) return false;
if (statusFilter === 'todo' && hasRating) return false;
}
if (ratingFilter !== 'all' && String(s.rating ?? '') !== ratingFilter)
return false;
return true;
});
}, [allSessions, statusFilter, ratingFilter]);
const totalItems =
activeTab === 'mentoring' ? (sessionsData?.total ?? sessions.length) : 0;
const columns: ColumnDef<TSessionListItem>[] = [ const columns: ColumnDef<TSessionListItem>[] = [
{ {
id: 'select', id: 'select',
meta: { cellClassName: cn('w-20') }, meta: { cellClassName: cn('w-10') },
header: ({ table }) => ( header: ({ table }) => <SelectAllCheckbox table={table} />,
<input cell: ({ row }) => <RowSelectCheckbox row={row} />,
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
}, },
{ {
id: 'name', id: 'name',
@@ -81,39 +110,29 @@ function FeedbackReviewDimentorinPage(): ReactElement {
header: 'Status', header: 'Status',
accessorKey: 'status', accessorKey: 'status',
cell: ({ row }) => { cell: ({ row }) => {
const hasRating = !!row.original.rating const hasRating = !!row.original.rating;
return ( return (
<div className={`py-2 px-4 rounded-md text-center ${hasRating ? 'bg-success-200 text-success-500' : 'bg-primary-200 text-primary-500'}`}> <Badge variant={hasRating ? 'success' : 'info'}>
{hasRating ? 'Done' : 'To Do'} {hasRating ? 'Done' : 'To Do'}
</div> </Badge>
) );
}, },
}, },
{ {
header: 'Action', header: 'Action',
meta: { cellClassName: cn('w-72') },
cell: () => ( cell: () => (
<Button <Button variant="secondary" size="sm">
variant="primary" <MessageSquare className="size-3.5" />
size="sm" Lihat Feedback
onClick={(e) => {
e.stopPropagation()
}}
className="flex items-center gap-2 w-max"
>
<SearchOutlined className="text-[16px]" /> Lihat Feedback
</Button> </Button>
), ),
}, },
] ];
const table = useReactTable({ const table = useReactTable({
data: sessions, data: sessions,
columns, columns,
state: { state: { pagination, rowSelection },
pagination,
rowSelection,
},
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
@@ -121,64 +140,85 @@ function FeedbackReviewDimentorinPage(): ReactElement {
onPaginationChange: setPagination, onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize), pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true, manualPagination: true,
}) });
return ( return (
<BackofficeWrapper title="Dimentorin.dev"> <BackofficeWrapper
<div className="mb-8 flex justify-between items-center"> title="Feedback & Review"
<h1 className="text-p1 font-semibold text-neutral-700">Feedback</h1> description="Review feedback dari mentoring & platform"
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md"> >
<For data={Object.values(TABS)}> <Card>
{(tab) => ( <CardContent className="pt-6">
<Button <Tabs
key={tab} value={activeTab}
variant="text" onValueChange={(v) => {
className={cn('px-3 py-2 capitalize', activeTab === tab && 'bg-white')} setActiveTab(v as 'mentoring' | 'platform');
onClick={() => { setPagination((p) => ({ ...p, pageIndex: 0 }));
setActiveTab(tab) }}
setPagination((p) => ({ ...p, pageIndex: 0 })) >
}} <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
> <TabsList>
{tab} <TabsTrigger value="mentoring">Mentoring</TabsTrigger>
</Button> <TabsTrigger value="platform">Platform</TabsTrigger>
)} </TabsList>
</For> <div className="flex flex-col gap-2 sm:flex-row">
</div> <div className="relative w-full sm:w-72">
</div> <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
<section className="flex flex-col gap-6 p-8 bg-white rounded-md"> className="pl-9"
<div className="flex justify-between items-center gap-5 mb-2"> placeholder="Cari nama mentor/mentee…"
<div className="relative w-full"> />
<Input </div>
placeholder="Cari berdasarkan nama mentor/mentee" <Select value={ratingFilter} onValueChange={setRatingFilter}>
className="pl-12 w-full max-h-full" <SelectTrigger className="w-28">
/> <SelectValue placeholder="Rating" />
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]"> </SelectTrigger>
<SearchOutlined /> <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>
</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 ? ( <TabsContent value="mentoring" className="mt-4">
<div className="text-center py-8 text-neutral-400">Loading...</div> {isLoading ? (
) : activeTab === TABS.PLATFORM ? ( <div className="py-10 text-center text-sm text-muted-foreground">
<div className="text-center py-8 text-neutral-400"> Memuat data…
Platform feedback tidak tersedia </div>
</div> ) : (
) : ( <DataTable
<DataTable data={sessions} columns={columns} table={table} /> data={sessions}
)} columns={columns}
</section> 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> </BackofficeWrapper>
) );
} }
@@ -1,14 +1,17 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router';
import * as React from 'react' import * as React from 'react';
import { Fragment, useState } from 'react' import { Search, Pencil, Trash2, Plus } from 'lucide-react';
import { import {
SearchOutlined, Button,
EditOutlined, Card,
DeleteOutlined, CardContent,
PlusOutlined, CardHeader,
} from '@ant-design/icons' Input,
import { Button, Input } from '@imphnen-frontend-service/ui/atoms' } from '@imphnen-frontend-service/ui/atoms';
import { DataTable } from '@imphnen-frontend-service/ui/organisms' import {
DataTable,
BackofficeWrapper,
} from '@imphnen-frontend-service/ui/organisms';
import { import {
ColumnDef, ColumnDef,
getCoreRowModel, getCoreRowModel,
@@ -16,143 +19,101 @@ import {
PaginationState, PaginationState,
useReactTable, useReactTable,
RowSelectionState, RowSelectionState,
} from '@tanstack/react-table' } from '@tanstack/react-table';
import { import {
useGachaItemList, useGachaItemList,
useDeleteGachaItem, useDeleteGachaItem,
TGachaItemDto, TGachaItemDto,
} from '@imphnen-frontend-service/service' } from '@imphnen-frontend-service/service';
import { toast } from 'sonner' import { toast } from 'sonner';
import {
SelectAllCheckbox,
RowSelectCheckbox,
DeleteConfirmDialog,
} from '../../components/list-helpers';
export const Route = createFileRoute('/_authenticated/gacha-roll')({ export const Route = createFileRoute('/_authenticated/gacha-roll')({
component: GachaRollPage, component: GachaRollPage,
}) });
function GachaRollPage() { function GachaRollPage() {
const navigate = useNavigate() const navigate = useNavigate();
const [search, setSearch] = useState('') const [search, setSearch] = React.useState('');
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = React.useState<string | null>(null);
const [pagination, setPagination] = React.useState<PaginationState>({ const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 10,
}) });
const [rowSelection, setRowSelection] =
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({}) React.useState<RowSelectionState>({});
const { data: itemsData, isLoading } = useGachaItemList({ const { data: itemsData, isLoading } = useGachaItemList({
search, search,
page: pagination.pageIndex + 1, page: pagination.pageIndex + 1,
per_page: pagination.pageSize, per_page: pagination.pageSize,
}) });
const deleteItem = useDeleteGachaItem() const deleteItem = useDeleteGachaItem();
const items: TGachaItemDto[] = itemsData?.data ?? [] const items: TGachaItemDto[] = itemsData?.data ?? [];
const totalItems = itemsData?.meta?.total ?? items.length const totalItems = itemsData?.meta?.total ?? items.length;
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { try {
await deleteItem.mutateAsync(id) await deleteItem.mutateAsync(id);
toast.success('Item berhasil dihapus') toast.success('Item berhasil dihapus');
setDeleteId(null) setDeleteId(null);
} catch (error) { } catch (error) {
console.log(error) console.log(error);
toast.error('Item gagal dihapus') toast.error('Item gagal dihapus');
} }
} };
const columns: ColumnDef<TGachaItemDto>[] = [ const columns: ColumnDef<TGachaItemDto>[] = [
{ {
id: 'select', id: 'select',
header: ({ table }) => ( header: ({ table }) => <SelectAllCheckbox table={table} />,
<input cell: ({ row }) => <RowSelectCheckbox row={row} />,
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'No',
accessorKey: 'id',
},
{
header: 'Nama Item',
accessorKey: 'name',
}, },
{ header: 'No', accessorKey: 'id' },
{ header: 'Nama Item', accessorKey: 'name' },
{ {
header: 'Action', header: 'Action',
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex gap-[8px]"> <div className="flex items-center gap-2">
<Button <Button
variant="primary" variant="secondary"
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
navigate({ to: '/gacha-roll/$id', params: { id: row.original.id } }) 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> </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> </div>
), ),
}, },
] ];
const table = useReactTable({ const table = useReactTable({
data: items, data: items,
columns, columns,
state: { state: { pagination, rowSelection },
pagination,
rowSelection,
},
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
@@ -160,48 +121,61 @@ function GachaRollPage() {
onPaginationChange: setPagination, onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize), pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true, manualPagination: true,
}) });
return ( return (
<Fragment> <BackofficeWrapper
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8"> title="Gacha Roll"
<header className="bg-white py-4 px-8 rounded-lg shadow p-4"> description="Kelola item hadiah gacha"
<h1 className="text-p2 font-semibold">Gacha Roll</h1> >
</header> <Card>
<CardHeader>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex justify-between items-center gap-8 mb-2"> <div className="relative w-full sm:max-w-sm">
<div className="relative w-full"> <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder="Cari berdasarkan nama item" placeholder="Cari nama item…"
className="pl-12 w-full max-h-full" className="pl-9"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex gap-3 text-nowrap"
onClick={() => navigate({ to: '/gacha-roll/create' })}
>
<PlusOutlined />
Tambah Item
</Button>
</div> </div>
<Button
onClick={() => navigate({ to: '/gacha-roll/create' })}
size="md"
>
<Plus className="size-4" />
Tambah Item
</Button>
</div> </div>
</CardHeader>
<CardContent>
{isLoading ? ( {isLoading ? (
<div className="text-center py-8 text-neutral-400">Loading...</div> <div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : ( ) : (
<DataTable data={items} columns={columns} table={table} /> <DataTable
data={items}
columns={columns}
table={table}
manualPagination
pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)} )}
</section> </CardContent>
</main> </Card>
</Fragment>
) <DeleteConfirmDialog
open={!!deleteId}
onOpenChange={(o) => !o && setDeleteId(null)}
onConfirm={() => deleteId && handleDelete(deleteId)}
title="Hapus item gacha ini?"
/>
</BackofficeWrapper>
);
} }
@@ -1,61 +1,82 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router';
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms' import * as React from 'react';
import { FC, ReactElement } from 'react' import { UsersRound, UserCog, ClipboardCheck } from 'lucide-react';
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import {
Card,
CardContent,
} from '@imphnen-frontend-service/ui/atoms';
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import { import {
getAdminUsers, getAdminUsers,
getAdminTeams, getAdminTeams,
getAdminSubmissions, getAdminSubmissions,
} from '@imphnen-frontend-service/service' } from '@imphnen-frontend-service/service';
export const Route = createFileRoute('/_authenticated/hackathon-dashboard')({ export const Route = createFileRoute('/_authenticated/hackathon-dashboard')({
component: HackathonDashboardPage, component: HackathonDashboardPage,
}) });
type StatCardProps = {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: React.ReactNode;
};
function StatCard({ icon: Icon, label, value }: StatCardProps) {
return (
<Card>
<CardContent className="flex items-center gap-4 pt-6">
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary-100 text-primary-600">
<Icon className="size-5" />
</div>
<div className="flex flex-col">
<span className="text-2xl font-semibold leading-tight text-foreground">
{value}
</span>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
</CardContent>
</Card>
);
}
function HackathonDashboardPage() { function HackathonDashboardPage() {
const { data: usersData } = useQuery({ const { data: usersData } = useQuery({
queryKey: ['admin-users-count'], queryKey: ['admin-users-count'],
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }), queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
}) });
const { data: teamsData } = useQuery({ const { data: teamsData } = useQuery({
queryKey: ['admin-teams-count'], queryKey: ['admin-teams-count'],
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }), queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
}) });
const { data: submissionsData } = useQuery({ const { data: submissionsData } = useQuery({
queryKey: ['admin-submissions-count'], queryKey: ['admin-submissions-count'],
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }), queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
}) });
const totalParticipants = usersData?.meta?.total_data ?? '??' const totalParticipants = usersData?.meta?.total_data ?? '—';
const totalTeams = teamsData?.meta?.total_data ?? '??' const totalTeams = teamsData?.meta?.total_data ?? '—';
const totalSubmissions = submissionsData?.meta?.total_data ?? '??' const totalSubmissions = submissionsData?.meta?.total_data ?? '—';
return ( return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025"> <BackofficeWrapper
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1> title="Hackathon Dashboard"
description="IMPHNEN x Kolosal.ai Hackathon 2025"
<section className="grid grid-cols-5 gap-5"> >
<div className="bg-white px-6 py-4 rounded-md shadow"> <section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5"> <StatCard
{totalParticipants} icon={UserCog}
</h3> label="Total Participants"
<p className="text-neutral-400 text-p3">Total Participants</p> value={totalParticipants}
</div> />
<div className="bg-white px-6 py-4 rounded-md shadow"> <StatCard icon={UsersRound} label="Total Teams" value={totalTeams} />
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5"> <StatCard
{totalTeams} icon={ClipboardCheck}
</h3> label="Total Project Submitted"
<p className="text-neutral-400 text-p3">Total Teams</p> value={totalSubmissions}
</div> />
<div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
{totalSubmissions}
</h3>
<p className="text-neutral-400 text-p3">Total Project Submitted</p>
</div>
</section> </section>
</BackofficeWrapper> </BackofficeWrapper>
) );
} }
@@ -1,34 +1,27 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { import * as React from 'react';
FC, import { Search, Eye } from 'lucide-react';
ReactElement, import SubmissionModal from './_components/hackathon-submissions/submission-modal';
useState,
useEffect,
useMemo,
useCallback,
} from 'react'
import SubmissionModal from './_components/hackathon-submissions/submission-modal'
import { import {
BackofficeWrapper, BackofficeWrapper,
DataTable, DataTable,
} from '@imphnen-frontend-service/ui/organisms' } from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { cn } from '@imphnen-frontend-service/utils'
import { import {
SearchOutlined, Badge,
FilterOutlined, Button,
LoadingOutlined, Card,
EyeOutlined, CardContent,
} from '@ant-design/icons' CardHeader,
import { useQuery } from '@tanstack/react-query' Input,
} from '@imphnen-frontend-service/ui/atoms';
import { useQuery } from '@tanstack/react-query';
import { import {
getAdminSubmissions, getAdminSubmissions,
TAdminSubmissionItem, TAdminSubmissionItem,
} from '@imphnen-frontend-service/service' } from '@imphnen-frontend-service/service';
import { useNavigate } from '@tanstack/react-router'
type SubmissionType = TAdminSubmissionItem type SubmissionType = TAdminSubmissionItem;
export const Route = createFileRoute('/_authenticated/hackathon-submissions')({ export const Route = createFileRoute('/_authenticated/hackathon-submissions')({
component: HackathonSubmissionsPage, component: HackathonSubmissionsPage,
@@ -38,20 +31,20 @@ export const Route = createFileRoute('/_authenticated/hackathon-submissions')({
per_page: Number(search.per_page) || 10, per_page: Number(search.per_page) || 10,
status: (search.status as string) || 'all', status: (search.status as string) || 'all',
}), }),
}) });
function HackathonSubmissionsPage() { function HackathonSubmissionsPage() {
const searchParams = Route.useSearch() const searchParams = Route.useSearch();
const navigate = useNavigate() const navigate = useNavigate();
const currentPage = Math.max(1, searchParams.page) const currentPage = Math.max(1, searchParams.page);
const searchQuery = searchParams.search || '' const searchQuery = searchParams.search || '';
const perPage = searchParams.per_page || 10 const perPage = searchParams.per_page || 10;
const statusFilter = searchParams.status || 'all' const statusFilter = searchParams.status || 'all';
const [showSubmissionModal, setShowSubmissionModal] = useState(false) const [showSubmissionModal, setShowSubmissionModal] = React.useState(false);
const [selectedSubmission, setSelectedSubmission] = const [selectedSubmission, setSelectedSubmission] =
useState<SubmissionType | null>(null) React.useState<SubmissionType | null>(null);
const [globalFilter, setGlobalFilter] = useState(searchQuery) const [globalFilter, setGlobalFilter] = React.useState(searchQuery);
const { const {
data: submissionsResponse, data: submissionsResponse,
@@ -74,12 +67,12 @@ function HackathonSubmissionsPage() {
}), }),
staleTime: 30000, staleTime: 30000,
gcTime: 5 * 60 * 1000, gcTime: 5 * 60 * 1000,
}) });
const totalData = submissionsResponse?.meta?.total_data || 0 const totalData = submissionsResponse?.meta?.total_data || 0;
const totalPages = submissionsResponse?.meta?.total_page || 1 const totalPages = submissionsResponse?.meta?.total_page || 1;
const handlePageChange = useCallback( const handlePageChange = React.useCallback(
(newPage: number) => { (newPage: number) => {
navigate({ navigate({
search: { search: {
@@ -88,23 +81,23 @@ function HackathonSubmissionsPage() {
search: searchQuery || undefined, search: searchQuery || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined, status: statusFilter !== 'all' ? statusFilter : undefined,
} as any, } as any,
}) });
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' });
}, },
[navigate, perPage, searchQuery, statusFilter] [navigate, perPage, searchQuery, statusFilter]
) );
useEffect(() => { React.useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) { if (!isLoading && totalPages > 0 && currentPage > totalPages) {
navigate({ search: { page: totalPages } as any }) navigate({ search: { page: totalPages } as any });
} }
}, [currentPage, totalPages, navigate, isLoading]) }, [currentPage, totalPages, navigate, isLoading]);
useEffect(() => { React.useEffect(() => {
setGlobalFilter(searchQuery) setGlobalFilter(searchQuery);
}, [searchQuery]) }, [searchQuery]);
const handleSearch = useCallback(() => { const handleSearch = React.useCallback(() => {
navigate({ navigate({
search: { search: {
page: 1, page: 1,
@@ -112,56 +105,29 @@ function HackathonSubmissionsPage() {
search: globalFilter.trim() || undefined, search: globalFilter.trim() || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined, status: statusFilter !== 'all' ? statusFilter : undefined,
} as any, } as any,
}) });
}, [globalFilter, navigate, perPage, statusFilter]) }, [globalFilter, navigate, perPage, statusFilter]);
const handleSearchKeyPress = useCallback( const filteredData = React.useMemo<SubmissionType[]>(() => {
(e: React.KeyboardEvent<HTMLInputElement>) => { return (
if (e.key === 'Enter') { ((submissionsResponse?.data as any)?.data as SubmissionType[]) ??
handleSearch() (submissionsResponse?.data as SubmissionType[]) ??
} []
}, );
[handleSearch] }, [submissionsResponse]);
)
const handlePerPageChange = useCallback( const statusVariants: Record<string, 'success' | 'warning' | 'secondary'> = {
(newPerPage: number) => { submitted: 'success',
navigate({ pending: 'warning',
search: { };
page: 1,
per_page: newPerPage,
search: searchQuery || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
} as any,
})
},
[navigate, searchQuery, statusFilter]
)
const handleShowSubmissionModal = useCallback( const columns: ColumnDef<SubmissionType>[] = React.useMemo(
(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(
() => [ () => [
{ {
accessorKey: 'project_name', accessorKey: 'project_name',
header: 'Project Name', header: 'Project Name',
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-medium text-neutral-900"> <span className="font-medium text-foreground">
{row.original.project_name} {row.original.project_name}
</span> </span>
), ),
@@ -171,7 +137,7 @@ function HackathonSubmissionsPage() {
accessorKey: 'team_id', accessorKey: 'team_id',
header: 'Team ID', header: 'Team ID',
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-sm text-neutral-700 font-mono"> <span className="font-mono text-xs text-muted-foreground">
{row.original.team_id} {row.original.team_id}
</span> </span>
), ),
@@ -180,30 +146,21 @@ function HackathonSubmissionsPage() {
{ {
accessorKey: 'status', accessorKey: 'status',
header: 'Status', header: 'Status',
cell: ({ row }) => { cell: ({ row }) => (
const status = row.original.status <Badge
return ( variant={statusVariants[row.original.status] ?? 'secondary'}
<span className="capitalize"
className={cn( >
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium', {row.original.status}
status === 'submitted' </Badge>
? '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>
)
},
enableSorting: true, enableSorting: true,
}, },
{ {
accessorKey: 'submitted_at', accessorKey: 'submitted_at',
header: 'Submitted', header: 'Submitted',
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-neutral-900 text-sm"> <span className="text-sm text-foreground">
{new Date(row.original.submitted_at).toLocaleDateString('en-US', { {new Date(row.original.submitted_at).toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
@@ -217,109 +174,87 @@ function HackathonSubmissionsPage() {
{ {
id: 'actions', id: 'actions',
header: 'Actions', header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => ( cell: ({ row }) => (
<Button <Button
variant="primary" variant="secondary"
size="sm" size="sm"
className="flex items-center gap-2 text-sm px-4 py-2" onClick={() => {
onClick={() => handleShowSubmissionModal(row.original)} setSelectedSubmission(row.original);
setShowSubmissionModal(true);
}}
> >
<EyeOutlined className="text-sm" /> <Eye className="size-3.5" />
View View
</Button> </Button>
), ),
enableSorting: false, enableSorting: false,
}, },
], ],
[handleShowSubmissionModal] []
) );
return ( return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025"> <BackofficeWrapper
<h1 className="mb-8 text-p1 font-semibold text-neutral-700"> title="Project Submissions"
Project Submissions description="IMPHNEN x Kolosal.ai Hackathon 2025"
</h1> >
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6"> <Card>
<div className="flex flex-wrap gap-3 items-center justify-between"> <CardHeader>
<div className="flex flex-wrap gap-3 items-center"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative"> <div className="relative w-full sm:max-w-sm">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" /> <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input <Input
type="text" className="pl-9"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none" placeholder="Cari nama project…"
placeholder="Search by project name..."
value={globalFilter} value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)} onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress} onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/> />
</div> </div>
</div>
<div className="relative"> </CardHeader>
<select <CardContent>
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" {isLoading ? (
value={perPage} <div className="py-12 text-center text-sm text-muted-foreground">
onChange={(e) => Memuat submissions…
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>
) : filteredData.length > 0 ? (
{} <>
{ <div className="mb-3 text-xs text-muted-foreground">
} Menampilkan {filteredData.length} dari {totalData} submissions
</div> (page {currentPage} / {totalPages})
</div> {isFetching && (
<span className="ml-2 text-primary-500">Updating…</span>
{} )}
{ </div>
} <DataTable
data={filteredData}
{isLoading ? ( columns={columns}
<div className="flex items-center justify-center py-12"> pageSize={perPage}
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" /> manualPagination
<span className="ml-3 text-neutral-600"> pageCount={totalPages}
Loading submissions... currentPage={currentPage}
</span> onPageChange={handlePageChange}
</div> />
) : filteredData.length > 0 ? ( </>
<> ) : (
<div className="text-sm text-neutral-600"> <div className="py-12 text-center text-sm text-muted-foreground">
Showing {filteredData.length} of {totalData} submissions (Page{' '} Tidak ada submissions.
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
)}
</div> </div>
<DataTable )}
data={filteredData} </CardContent>
columns={columns} </Card>
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>
{selectedSubmission && ( {selectedSubmission && (
<SubmissionModal <SubmissionModal
isOpen={showSubmissionModal} isOpen={showSubmissionModal}
onClose={handleCloseSubmissionModal} onClose={() => {
setShowSubmissionModal(false);
setSelectedSubmission(null);
}}
submission={selectedSubmission} submission={selectedSubmission}
/> />
)} )}
</BackofficeWrapper> </BackofficeWrapper>
) );
} }
@@ -1,37 +1,30 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { import * as React from 'react';
FC, import { Search, Plus, Users as TeamIcon, Pencil, X } from 'lucide-react';
ReactElement, import ModalTeamDetail from './_components/hackathon-teams/modal-team-detail-new';
useState,
useEffect,
useMemo,
useCallback,
} from 'react'
import ModalTeamDetail from './_components/hackathon-teams/modal-team-detail-new'
import { CityFilterSelect } from '../../components/city-filter-select'
import { import {
BackofficeWrapper, BackofficeWrapper,
DataTable, DataTable,
} from '@imphnen-frontend-service/ui/organisms' } from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { cn } from '@imphnen-frontend-service/utils'
import { import {
EditOutlined, Avatar,
TeamOutlined, AvatarFallback,
SearchOutlined, AvatarImage,
FilterOutlined, Badge,
PlusOutlined, Button,
LoadingOutlined, Card,
} from '@ant-design/icons' CardContent,
import { useQuery } from '@tanstack/react-query' CardHeader,
Input,
} from '@imphnen-frontend-service/ui/atoms';
import { useQuery } from '@tanstack/react-query';
import { import {
getAdminTeams, getAdminTeams,
TAdminTeamItem, TAdminTeamItem,
} from '@imphnen-frontend-service/service' } from '@imphnen-frontend-service/service';
import { useNavigate } from '@tanstack/react-router'
type TeamType = TAdminTeamItem type TeamType = TAdminTeamItem;
export const Route = createFileRoute('/_authenticated/hackathon-teams')({ export const Route = createFileRoute('/_authenticated/hackathon-teams')({
component: HackathonTeamsPage, component: HackathonTeamsPage,
@@ -40,22 +33,21 @@ export const Route = createFileRoute('/_authenticated/hackathon-teams')({
search: (search.search as string) || '', search: (search.search as string) || '',
per_page: Number(search.per_page) || 10, per_page: Number(search.per_page) || 10,
}), }),
}) });
function HackathonTeamsPage() { function HackathonTeamsPage() {
const searchParams = Route.useSearch() const searchParams = Route.useSearch();
const navigate = useNavigate() const navigate = useNavigate();
const currentPage = Math.max(1, searchParams.page) const currentPage = Math.max(1, searchParams.page);
const searchQuery = searchParams.search || '' const searchQuery = searchParams.search || '';
const perPage = searchParams.per_page || 10 const perPage = searchParams.per_page || 10;
const [showDetailModal, setShowDetailModal] = useState(false)
const [showNewTeamModal, setShowNewTeamModal] = useState(false)
const [selectedTeam, setSelectedTeam] = useState<TeamType | null>(null)
useState<TeamType | null>(null)
const [globalFilter, setGlobalFilter] = useState(searchQuery)
const [visibilityFilter, setVisibilityFilter] = useState('all') const [showDetailModal, setShowDetailModal] = React.useState(false);
const [cityFilter, setCityFilter] = useState('all') const [showNewTeamModal, setShowNewTeamModal] = React.useState(false);
const [selectedTeam, setSelectedTeam] = React.useState<TeamType | null>(null);
const [globalFilter, setGlobalFilter] = React.useState(searchQuery);
const [visibilityFilter, setVisibilityFilter] = React.useState('all');
const [cityFilter, setCityFilter] = React.useState('all');
const { const {
data: teamsResponse, data: teamsResponse,
@@ -78,12 +70,12 @@ function HackathonTeamsPage() {
}), }),
staleTime: 30000, staleTime: 30000,
gcTime: 5 * 60 * 1000, gcTime: 5 * 60 * 1000,
}) });
const totalData = teamsResponse?.meta?.total_data || 0 const totalData = teamsResponse?.meta?.total_data || 0;
const totalPages = teamsResponse?.meta?.total_page || 1 const totalPages = teamsResponse?.meta?.total_page || 1;
const handlePageChange = useCallback( const handlePageChange = React.useCallback(
(newPage: number) => { (newPage: number) => {
navigate({ navigate({
search: { search: {
@@ -91,142 +83,97 @@ function HackathonTeamsPage() {
per_page: perPage !== 10 ? perPage : undefined, per_page: perPage !== 10 ? perPage : undefined,
search: searchQuery || undefined, search: searchQuery || undefined,
} as any, } as any,
}) });
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' });
}, },
[navigate, perPage, searchQuery] [navigate, perPage, searchQuery]
) );
useEffect(() => { React.useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) { if (!isLoading && totalPages > 0 && currentPage > totalPages) {
navigate({ search: { page: totalPages } as any }) navigate({ search: { page: totalPages } as any });
} }
}, [currentPage, totalPages, navigate, isLoading]) }, [currentPage, totalPages, navigate, isLoading]);
useEffect(() => { React.useEffect(() => {
setGlobalFilter(searchQuery) setGlobalFilter(searchQuery);
}, [searchQuery]) }, [searchQuery]);
const handleSearch = useCallback(() => { const handleSearch = React.useCallback(() => {
navigate({ navigate({
search: { search: {
page: 1, page: 1,
per_page: perPage !== 10 ? perPage : undefined, per_page: perPage !== 10 ? perPage : undefined,
search: globalFilter.trim() || undefined, search: globalFilter.trim() || undefined,
} as any, } as any,
}) });
}, [globalFilter, navigate, perPage]) }, [globalFilter, navigate, perPage]);
const handleSearchKeyPress = useCallback( const filteredData = React.useMemo<TeamType[]>(() => {
(e: React.KeyboardEvent<HTMLInputElement>) => { return (
if (e.key === 'Enter') { ((teamsResponse?.data as any)?.data as TeamType[]) ??
handleSearch() (teamsResponse?.data as TeamType[]) ??
} []
}, );
[handleSearch] }, [teamsResponse]);
)
const handlePerPageChange = useCallback( const handleShowDetailModal = React.useCallback((team: TeamType) => {
(newPerPage: number) => { setSelectedTeam(team);
navigate({ setShowDetailModal(true);
search: { }, []);
page: 1,
per_page: newPerPage,
search: searchQuery || undefined,
} as any,
})
},
[navigate, searchQuery]
)
const handleShowDetailModal = useCallback((team: TeamType) => { const columns: ColumnDef<TeamType>[] = React.useMemo(
setSelectedTeam(team)
setShowDetailModal(true)
}, [])
const handleCloseDetailModal = useCallback(() => {
setShowDetailModal(false)
setSelectedTeam(null)
}, [])
const handleShowNewTeamModal = useCallback(() => {
setShowNewTeamModal(true)
}, [])
const handleCloseNewTeamModal = useCallback(() => {
setShowNewTeamModal(false)
}, [])
const filteredData = useMemo(() => {
return teamsResponse?.data?.data || teamsResponse?.data || []
}, [teamsResponse])
const columns: ColumnDef<TeamType>[] = useMemo(
() => [ () => [
{ {
accessorKey: 'name', accessorKey: 'name',
header: 'Team', header: 'Team',
cell: ({ row }) => { cell: ({ row }) => (
const team = row.original <div className="flex items-center gap-3">
return ( <Avatar>
<div className="flex items-center gap-3"> <AvatarImage src={row.original.logo ?? undefined} alt={row.original.name} />
<div className="w-10 h-10 rounded-full bg-neutral-100 flex items-center justify-center shrink-0 overflow-hidden"> <AvatarFallback>
{team.logo ? ( <TeamIcon className="size-4 text-muted-foreground" />
<img </AvatarFallback>
src={team.logo} </Avatar>
alt={team.name} <div className="min-w-0 flex-1">
className="w-full h-full object-cover" <p
/> className="truncate font-medium text-foreground"
) : ( title={row.original.name}
<TeamOutlined className="text-neutral-400 text-lg" /> >
)} {row.original.name}
</div> </p>
<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>
</div> </div>
) </div>
}, ),
enableSorting: true, enableSorting: true,
}, },
{ {
accessorKey: 'city', accessorKey: 'city',
header: 'City', header: 'City',
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-neutral-700">{row.original.city}</span> <span className="text-foreground">{row.original.city}</span>
), ),
enableSorting: true, enableSorting: true,
}, },
{ {
accessorKey: 'visibility', accessorKey: 'visibility',
header: 'Visibility', header: 'Visibility',
cell: ({ row }) => { cell: ({ row }) => (
const isPublic = row.original.visibility === 'public' <Badge
return ( variant={
<span row.original.visibility === 'public' ? 'success' : 'secondary'
className={cn( }
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium', >
isPublic {row.original.visibility === 'public' ? 'Public' : 'Private'}
? 'bg-success-100 text-success-800' </Badge>
: 'bg-neutral-100 text-neutral-700' ),
)}
>
{isPublic ? 'Public' : 'Private'}
</span>
)
},
enableSorting: true, enableSorting: true,
}, },
{ {
id: 'leader', id: 'leader',
header: 'Leader ID', header: 'Leader ID',
cell: ({ row }) => ( cell: ({ row }) => (
<div className="text-sm text-neutral-700 font-mono"> <div className="font-mono text-xs text-muted-foreground">
{row.original.leader_id} {row.original.leader_id}
</div> </div>
), ),
@@ -236,7 +183,7 @@ function HackathonTeamsPage() {
accessorKey: 'created_at', accessorKey: 'created_at',
header: 'Created', header: 'Created',
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-neutral-900 text-sm"> <span className="text-sm text-foreground">
{new Date(row.original.created_at).toLocaleDateString('en-UK', { {new Date(row.original.created_at).toLocaleDateString('en-UK', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
@@ -250,168 +197,127 @@ function HackathonTeamsPage() {
{ {
id: 'actions', id: 'actions',
header: 'Actions', header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center gap-2"> <Button
<Button variant="secondary"
variant="primary" size="sm"
size="sm" onClick={() => handleShowDetailModal(row.original)}
className="flex items-center gap-2 text-sm px-4 py-2" >
onClick={() => handleShowDetailModal(row.original)} <Pencil className="size-3.5" />
> Manage
<EditOutlined className="text-sm" /> </Button>
Manage
</Button>
</div>
), ),
enableSorting: false, enableSorting: false,
}, },
], ],
[handleShowDetailModal] [handleShowDetailModal]
) );
const hasActiveFilters = visibilityFilter !== 'all' || cityFilter !== 'all';
return ( return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025"> <BackofficeWrapper
<h1 className="mb-8 text-p1 font-semibold text-neutral-700"> title="Hackathon Teams"
Team Management description="IMPHNEN x Kolosal.ai Hackathon 2025"
</h1> >
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6"> <Card>
<div className="flex flex-wrap gap-3 items-center justify-between"> <CardHeader>
<div className="flex flex-wrap gap-3 items-center"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative"> <div className="relative w-full sm:max-w-sm">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" /> <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input <Input
type="text" className="pl-9"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none" placeholder="Cari nama atau kota…"
placeholder="Search teams by name or city..."
value={globalFilter} value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)} onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress} onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/> />
</div> </div>
<Button onClick={() => setShowNewTeamModal(true)} size="md">
<div className="relative"> <Plus className="size-4" />
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</div>
{}
{
}
{}
{
}
</div>
<div className="flex items-center gap-3">
<Button
variant="primary"
size="md"
className="flex items-center gap-2 px-4 py-2"
onClick={handleShowNewTeamModal}
>
<PlusOutlined className="text-sm" />
Add Team Add Team
</Button> </Button>
</div> </div>
</div> </CardHeader>
<CardContent className="space-y-4">
{(visibilityFilter !== 'all' || cityFilter !== 'all') && ( {hasActiveFilters && (
<div className="flex flex-wrap gap-2 items-center"> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-neutral-600">Active filters:</span> <span className="text-sm text-muted-foreground">
Active filters:
{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>
</span> </span>
)} {visibilityFilter !== 'all' && (
<Badge variant="info" className="gap-1">
{cityFilter !== 'all' && ( Visibility: {visibilityFilter}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm"> <button onClick={() => setVisibilityFilter('all')}>
City: {cityFilter} <X className="size-3" />
<button </button>
onClick={() => setCityFilter('all')} </Badge>
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>
)} )}
{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> </div>
<DataTable )}
data={filteredData} {isLoading ? (
columns={columns} <div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
pageSize={perPage} Memuat data teams…
manualPagination={true} </div>
pageCount={totalPages} ) : filteredData.length > 0 ? (
currentPage={currentPage} <>
onPageChange={handlePageChange} <div className="text-xs text-muted-foreground">
/> Menampilkan {filteredData.length} dari {totalData} teams (page{' '}
</> {currentPage} / {totalPages})
) : ( {isFetching && (
<div className="text-center py-12 text-neutral-500"> <span className="ml-2 text-primary-500">Updating…</span>
No teams found. Try adjusting your filters. )}
</div> </div>
)} <DataTable
</section> 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 <ModalTeamDetail
isOpen={showDetailModal} isOpen={showDetailModal}
onClose={handleCloseDetailModal} onClose={() => {
setShowDetailModal(false);
setSelectedTeam(null);
}}
team={selectedTeam} team={selectedTeam}
/> />
<ModalTeamDetail <ModalTeamDetail
isOpen={showNewTeamModal} isOpen={showNewTeamModal}
onClose={handleCloseNewTeamModal} onClose={() => setShowNewTeamModal(false)}
team={null} team={null}
/> />
</BackofficeWrapper> </BackofficeWrapper>
) );
} }
@@ -1,48 +1,31 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { import * as React from 'react';
FC, import { Search, Plus, User, Pencil, X } from 'lucide-react';
ReactElement, import ModalUserDetail from './_components/hackathon-users/modal-user-detail';
useState,
useEffect,
useMemo,
useCallback,
} from 'react'
import ModalUserDetail from './_components/hackathon-users/modal-user-detail'
import { import {
BackofficeWrapper, BackofficeWrapper,
DataTable, DataTable,
} from '@imphnen-frontend-service/ui/organisms' } from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table' import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { cn } from '@imphnen-frontend-service/utils'
import { import {
EditOutlined, Avatar,
UserOutlined, AvatarFallback,
SearchOutlined, AvatarImage,
FilterOutlined, Badge,
PlusOutlined, Button,
LoadingOutlined, Card,
} from '@ant-design/icons' CardContent,
import { CityFilterSelect } from '../../components/city-filter-select' CardHeader,
import { useQuery } from '@tanstack/react-query' Input,
} from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import { useQuery } from '@tanstack/react-query';
import { import {
getAdminUsers, getAdminUsers,
TAdminUserItem, TAdminUserItem,
} from '@imphnen-frontend-service/service' } from '@imphnen-frontend-service/service';
import { useNavigate } from '@tanstack/react-router'
type UserType = TAdminUserItem type UserType = TAdminUserItem;
const skillsOptions = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
]
export const Route = createFileRoute('/_authenticated/hackathon-users')({ export const Route = createFileRoute('/_authenticated/hackathon-users')({
component: HackathonUsersPage, component: HackathonUsersPage,
@@ -51,36 +34,28 @@ export const Route = createFileRoute('/_authenticated/hackathon-users')({
search: (search.search as string) || '', search: (search.search as string) || '',
per_page: Number(search.per_page) || 10, per_page: Number(search.per_page) || 10,
}), }),
}) });
function HackathonUsersPage() { function HackathonUsersPage() {
const searchParams = Route.useSearch() const searchParams = Route.useSearch();
const navigate = useNavigate() const navigate = useNavigate();
const currentPage = Math.max(1, searchParams.page) const currentPage = Math.max(1, searchParams.page);
const searchQuery = searchParams.search || '' const searchQuery = searchParams.search || '';
const perPage = searchParams.per_page || 10 const perPage = searchParams.per_page || 10;
const [showDetailModal, setShowDetailModal] = useState(false)
const [showNewUserModal, setShowNewUserModal] = useState(false)
const [selectedUser, setSelectedUser] = useState<UserType | null>(null)
const [globalFilter, setGlobalFilter] = useState(searchQuery)
const [statusFilter, setStatusFilter] = useState('all') const [showDetailModal, setShowDetailModal] = React.useState(false);
const [cityFilter, setCityFilter] = useState('all') const [showNewUserModal, setShowNewUserModal] = React.useState(false);
const [skillsFilter, setSkillsFilter] = useState<string[]>([]) const [selectedUser, setSelectedUser] = React.useState<UserType | null>(null);
const [globalFilter, setGlobalFilter] = React.useState(searchQuery);
const [statusFilter, setStatusFilter] = React.useState('all');
const [skillsFilter, setSkillsFilter] = React.useState<string[]>([]);
const { const {
data: usersResponse, data: usersResponse,
isLoading, isLoading,
isFetching, isFetching,
} = useQuery({ } = useQuery({
queryKey: [ queryKey: ['admin-users', currentPage, perPage, statusFilter, searchQuery],
'admin-users',
currentPage,
perPage,
cityFilter,
statusFilter,
searchQuery,
],
queryFn: () => queryFn: () =>
getAdminUsers({ getAdminUsers({
page: currentPage, page: currentPage,
@@ -89,12 +64,12 @@ function HackathonUsersPage() {
}), }),
staleTime: 30000, staleTime: 30000,
gcTime: 5 * 60 * 1000, gcTime: 5 * 60 * 1000,
}) });
const totalData = usersResponse?.meta?.total_data || 0 const totalData = usersResponse?.meta?.total_data || 0;
const totalPages = usersResponse?.meta?.total_page || 1 const totalPages = usersResponse?.meta?.total_page || 1;
const handlePageChange = useCallback( const handlePageChange = React.useCallback(
(newPage: number) => { (newPage: number) => {
navigate({ navigate({
search: { search: {
@@ -102,112 +77,73 @@ function HackathonUsersPage() {
per_page: perPage !== 10 ? perPage : undefined, per_page: perPage !== 10 ? perPage : undefined,
search: searchQuery || undefined, search: searchQuery || undefined,
} as any, } as any,
}) });
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' });
}, },
[navigate, perPage, searchQuery] [navigate, perPage, searchQuery]
) );
useEffect(() => { React.useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) { if (!isLoading && totalPages > 0 && currentPage > totalPages) {
navigate({ search: { page: totalPages } as any }) navigate({ search: { page: totalPages } as any });
} }
}, [currentPage, totalPages, navigate, isLoading]) }, [currentPage, totalPages, navigate, isLoading]);
useEffect(() => { React.useEffect(() => {
setGlobalFilter(searchQuery) setGlobalFilter(searchQuery);
}, [searchQuery]) }, [searchQuery]);
const handleSearch = useCallback(() => { const handleSearch = React.useCallback(() => {
navigate({ navigate({
search: { search: {
page: 1, page: 1,
per_page: perPage !== 10 ? perPage : undefined, per_page: perPage !== 10 ? perPage : undefined,
search: globalFilter.trim() || undefined, search: globalFilter.trim() || undefined,
} as any, } as any,
}) });
}, [globalFilter, navigate, perPage]) }, [globalFilter, navigate, perPage]);
const handleSearchKeyPress = useCallback( const handleShowDetailModal = React.useCallback((user: UserType) => {
(e: React.KeyboardEvent<HTMLInputElement>) => { setSelectedUser(user);
if (e.key === 'Enter') { setShowDetailModal(true);
handleSearch() }, []);
}
},
[handleSearch]
)
const handlePerPageChange = useCallback( const filteredData = React.useMemo(() => {
(newPerPage: number) => { const usersData: UserType[] =
navigate({ ((usersResponse?.data as any)?.data as UserType[]) ??
search: { (usersResponse?.data as UserType[]) ??
page: 1, [];
per_page: newPerPage, return usersData.filter((user) => {
search: searchQuery || undefined,
} as any,
})
},
[navigate, searchQuery]
)
const handleShowDetailModal = useCallback((user: UserType) => {
setSelectedUser(user)
setShowDetailModal(true)
}, [])
const handleCloseDetailModal = useCallback(() => {
setShowDetailModal(false)
setSelectedUser(null)
}, [])
const handleShowNewUserModal = useCallback(() => {
setShowNewUserModal(true)
}, [])
const handleCloseNewUserModal = useCallback(() => {
setShowNewUserModal(false)
}, [])
const filteredData = useMemo(() => {
const usersData = usersResponse?.data?.data || usersResponse?.data || []
return usersData.filter((user: UserType) => {
if (statusFilter !== 'all') { if (statusFilter !== 'all') {
const isActive = statusFilter === 'active' const isActive = statusFilter === 'active';
if (user.is_active !== isActive) return false if (user.is_active !== isActive) return false;
} }
if (skillsFilter.length > 0) { if (skillsFilter.length > 0) {
const userSkills = user.skills || [] const userSkills = user.skills || [];
const hasMatchingSkill = skillsFilter.some((skill) => const hasMatchingSkill = skillsFilter.some((skill) =>
userSkills.includes(skill) userSkills.includes(skill)
) );
if (!hasMatchingSkill) return false if (!hasMatchingSkill) return false;
} }
return true;
});
}, [usersResponse, statusFilter, skillsFilter]);
return true const columns: ColumnDef<UserType>[] = React.useMemo(
})
}, [usersResponse, statusFilter, skillsFilter])
const columns: ColumnDef<UserType>[] = useMemo(
() => [ () => [
{ {
accessorKey: 'fullname', accessorKey: 'fullname',
header: 'User', header: 'User',
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0"> <Avatar className="size-9">
{row.original.avatar ? ( <AvatarImage src={row.original.avatar ?? undefined} alt={row.original.fullname} />
<img <AvatarFallback>
src={row.original.avatar} <User className="size-4 text-muted-foreground" />
alt={row.original.fullname} </AvatarFallback>
className="w-full h-full object-cover" </Avatar>
/>
) : (
<UserOutlined className="text-neutral-500 text-lg" />
)}
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-medium text-neutral-900 truncate"> <p className="truncate font-medium text-foreground">
{row.original.fullname} {row.original.fullname}
</p> </p>
</div> </div>
@@ -219,30 +155,22 @@ function HackathonUsersPage() {
accessorKey: 'skills', accessorKey: 'skills',
header: 'Skills', header: 'Skills',
cell: ({ row }) => { cell: ({ row }) => {
const skills = row.original.skills || [] const skills = row.original.skills || [];
if (skills.length === 0) {
return <span className="text-muted-foreground">-</span>;
}
return ( return (
<div className="flex flex-wrap gap-1 max-w-xs"> <div className="flex flex-wrap gap-1">
{skills.length > 0 ? ( {skills.slice(0, 2).map((skill, index) => (
<> <Badge key={index} variant="success">
{skills.slice(0, 2).map((skill, index) => ( {skill.replace(' Developer', '').replace(' Engineer', '')}
<span </Badge>
key={index} ))}
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800" {skills.length > 2 && (
> <Badge variant="secondary">+{skills.length - 2}</Badge>
{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> </div>
) );
}, },
enableSorting: false, enableSorting: false,
}, },
@@ -250,7 +178,7 @@ function HackathonUsersPage() {
accessorKey: 'location', accessorKey: 'location',
header: 'Location', header: 'Location',
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-neutral-700">{row.original.location}</span> <span className="text-foreground">{row.original.location}</span>
), ),
enableSorting: true, enableSorting: true,
}, },
@@ -259,16 +187,16 @@ function HackathonUsersPage() {
header: 'Status', header: 'Status',
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <span
className={cn( className={cn(
'w-2 h-2 rounded-full', 'size-2 rounded-full',
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400' row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
)} )}
/> />
<span <span
className={cn( className={cn(
'text-sm font-medium', 'text-sm font-medium',
row.original.is_active ? 'text-success-700' : 'text-neutral-500' row.original.is_active ? 'text-success-700' : 'text-muted-foreground'
)} )}
> >
{row.original.is_active ? 'Active' : 'Inactive'} {row.original.is_active ? 'Active' : 'Inactive'}
@@ -277,18 +205,18 @@ function HackathonUsersPage() {
), ),
enableSorting: true, enableSorting: true,
sortingFn: (rowA, rowB) => { sortingFn: (rowA, rowB) => {
const aActive = rowA.original.is_active const a = rowA.original.is_active;
const bActive = rowB.original.is_active const b = rowB.original.is_active;
if (aActive && !bActive) return -1 if (a && !b) return -1;
if (!aActive && bActive) return 1 if (!a && b) return 1;
return 0 return 0;
}, },
}, },
{ {
accessorKey: 'created_at', accessorKey: 'created_at',
header: 'Joined', header: 'Joined',
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-neutral-900 text-sm"> <span className="text-sm text-foreground">
{new Date(row.original.created_at).toLocaleDateString('en-US', { {new Date(row.original.created_at).toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
@@ -302,194 +230,135 @@ function HackathonUsersPage() {
{ {
id: 'actions', id: 'actions',
header: 'Actions', header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center gap-2"> <Button
<Button variant="secondary"
variant="primary" size="sm"
size="sm" onClick={() => handleShowDetailModal(row.original)}
className="flex items-center gap-2 text-sm px-4 py-2" >
onClick={() => handleShowDetailModal(row.original)} <Pencil className="size-3.5" />
> Manage
<EditOutlined className="text-sm" /> </Button>
Manage
</Button>
{
}
</div>
), ),
enableSorting: false, enableSorting: false,
}, },
], ],
[handleShowDetailModal] [handleShowDetailModal]
) );
const hasActiveFilters = statusFilter !== 'all' || skillsFilter.length > 0;
return ( return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025"> <BackofficeWrapper
<h1 className="mb-8 text-p1 font-semibold text-neutral-700"> title="Hackathon Users"
User Management description="IMPHNEN x Kolosal.ai Hackathon 2025"
</h1> >
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6"> <Card>
<div className="flex flex-wrap gap-3 items-center justify-between"> <CardHeader>
<div className="flex flex-wrap gap-3 items-center"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative"> <div className="relative w-full sm:max-w-sm">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" /> <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input <Input
type="text" className="pl-9"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none" placeholder="Cari nama atau lokasi…"
placeholder="Search users by name or location..."
value={globalFilter} value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)} onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress} onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/> />
</div> </div>
<Button onClick={() => setShowNewUserModal(true)} size="md">
<div className="relative"> <Plus className="size-4" />
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</div>
{}
{
}
{}
{
}
{}
{
}
</div>
<div className="flex items-center gap-3">
<Button
variant="primary"
size="md"
className="flex items-center gap-2 px-4 py-2"
onClick={handleShowNewUserModal}
>
<PlusOutlined className="text-sm" />
Add User Add User
</Button> </Button>
</div> </div>
</div> </CardHeader>
<CardContent className="space-y-4">
{(skillsFilter.length > 0 || {hasActiveFilters && (
statusFilter !== 'all' || <div className="flex flex-wrap items-center gap-2">
cityFilter !== 'all') && ( <span className="text-sm text-muted-foreground">
<div className="flex flex-wrap gap-2 items-center"> Active filters:
<span className="text-sm text-neutral-600">Active filters:</span>
{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>
</span> </span>
)} {statusFilter !== 'all' && (
<Badge variant="info" className="gap-1">
{cityFilter !== 'all' && ( Status: {statusFilter}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm"> <button
City: {cityFilter} onClick={() => setStatusFilter('all')}
<button aria-label="Clear status filter"
onClick={() => setCityFilter('all')} >
className="text-green-600 hover:text-green-800 cursor-pointer" <X className="size-3" />
> </button>
✕ </Badge>
</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>
)} )}
{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> </div>
<DataTable )}
data={filteredData} {isLoading ? (
columns={columns} <div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
pageSize={perPage} <span>Memuat data users…</span>
manualPagination={true} </div>
pageCount={totalPages} ) : filteredData.length > 0 ? (
currentPage={currentPage} <>
onPageChange={handlePageChange} <div className="text-xs text-muted-foreground">
/> Menampilkan {filteredData.length} dari {totalData} users (page{' '}
</> {currentPage} / {totalPages})
) : ( {isFetching && (
<div className="text-center py-12 text-neutral-500"> <span className="ml-2 text-primary-500">Updating…</span>
No users found. Try adjusting your filters. )}
</div> </div>
)} <DataTable
</section> 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 <ModalUserDetail
isOpen={showDetailModal} isOpen={showDetailModal}
onClose={handleCloseDetailModal} onClose={() => {
setShowDetailModal(false);
setSelectedUser(null);
}}
user={selectedUser} user={selectedUser}
/> />
<ModalUserDetail <ModalUserDetail
isOpen={showNewUserModal} isOpen={showNewUserModal}
onClose={handleCloseNewUserModal} onClose={() => setShowNewUserModal(false)}
user={null} user={null}
/> />
</BackofficeWrapper> </BackofficeWrapper>
) );
} }
@@ -1,13 +1,17 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { Fragment, useState } from 'react' import * as React from 'react';
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
import { import {
SearchOutlined, Button,
EditOutlined, Card,
DeleteOutlined, CardContent,
PlusOutlined, CardHeader,
} from '@ant-design/icons' Input,
import { Button, Input } from '@imphnen-frontend-service/ui/atoms' } from '@imphnen-frontend-service/ui/atoms';
import { DataTable } from '@imphnen-frontend-service/ui/organisms' import {
DataTable,
BackofficeWrapper,
} from '@imphnen-frontend-service/ui/organisms';
import { import {
ColumnDef, ColumnDef,
getCoreRowModel, getCoreRowModel,
@@ -15,144 +19,101 @@ import {
PaginationState, PaginationState,
RowSelectionState, RowSelectionState,
useReactTable, useReactTable,
} from '@tanstack/react-table' } from '@tanstack/react-table';
import { import {
usePermissionList, usePermissionList,
useDeletePermission, useDeletePermission,
TPermissionItem, TPermissionItem,
} from '@imphnen-frontend-service/service' } from '@imphnen-frontend-service/service';
import React from 'react' import { toast } from 'sonner';
import { toast } from 'sonner' import {
SelectAllCheckbox,
RowSelectCheckbox,
DeleteConfirmDialog,
} from '../../components/list-helpers';
export const Route = createFileRoute('/_authenticated/permissions')({ export const Route = createFileRoute('/_authenticated/permissions')({
component: PermissionsPage, component: PermissionsPage,
}) });
function PermissionsPage() { function PermissionsPage() {
const navigate = useNavigate() const navigate = useNavigate();
const [search, setSearch] = useState('') const [search, setSearch] = React.useState('');
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = React.useState<string | null>(null);
const [pagination, setPagination] = React.useState<PaginationState>({ const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 10,
}) });
const [rowSelection, setRowSelection] =
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({}) React.useState<RowSelectionState>({});
const { data: permissionsData, isLoading } = usePermissionList({ const { data: permissionsData, isLoading } = usePermissionList({
search, search,
page: pagination.pageIndex + 1, page: pagination.pageIndex + 1,
per_page: pagination.pageSize, per_page: pagination.pageSize,
}) });
const deletePermission = useDeletePermission() const deletePermission = useDeletePermission();
const permissions: TPermissionItem[] = permissionsData?.data ?? [] const permissions: TPermissionItem[] = permissionsData?.data ?? [];
const totalItems = permissionsData?.meta?.total ?? permissions.length const totalItems = permissionsData?.meta?.total ?? permissions.length;
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { try {
await deletePermission.mutateAsync(id) await deletePermission.mutateAsync(id);
toast.success('Data permissions berhasil dihapus') toast.success('Data permission berhasil dihapus');
setDeleteId(null) setDeleteId(null);
} catch (error) { } catch (error) {
console.log(error) console.log(error);
toast.error('Data permissions gagal dihapus') toast.error('Data permission gagal dihapus');
} }
} };
const columns: ColumnDef<TPermissionItem>[] = [ const columns: ColumnDef<TPermissionItem>[] = [
{ {
id: 'select', id: 'select',
header: ({ table }) => ( header: ({ table }) => <SelectAllCheckbox table={table} />,
<input cell: ({ row }) => <RowSelectCheckbox row={row} />,
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'No',
accessorKey: 'id',
},
{
header: 'Name',
accessorKey: 'name',
}, },
{ header: 'No', accessorKey: 'id' },
{ header: 'Name', accessorKey: 'name' },
{ {
header: 'Action', header: 'Action',
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex gap-[8px]"> <div className="flex items-center gap-2">
<Button <Button
variant="primary" variant="secondary"
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
navigate({ to: '/permissions/$id', params: { id: row.original.id } }) 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> </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> </div>
), ),
}, },
] ];
const table = useReactTable({ const table = useReactTable({
data: permissions, data: permissions,
columns, columns,
state: { state: { pagination, rowSelection },
pagination,
rowSelection,
},
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
@@ -160,53 +121,61 @@ function PermissionsPage() {
onPaginationChange: setPagination, onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize), pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true, manualPagination: true,
}) });
return ( return (
<Fragment> <BackofficeWrapper
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8"> title="Permissions"
<header className="bg-white py-4 px-8 rounded-lg shadow p-4"> description="Kelola hak akses sistem"
<h1 className="text-p2 font-semibold">Permissions</h1> >
</header> <Card>
<CardHeader>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex justify-between items-center gap-8 mb-2"> <div className="relative w-full sm:max-w-sm">
<div className="relative w-full"> <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder="Cari berdasarkan nama permissions" placeholder="Cari nama permission…"
className="pl-12 w-full max-h-full" className="pl-9"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex gap-3 text-nowrap"
onClick={() => navigate({ to: '/permissions/create' })}
>
<PlusOutlined />
Tambah Permissions
</Button>
</div> </div>
<Button
onClick={() => navigate({ to: '/permissions/create' })}
size="md"
>
<Plus className="size-4" />
Tambah Permission
</Button>
</div> </div>
</CardHeader>
<CardContent>
{isLoading ? ( {isLoading ? (
<div className="text-center py-8 text-neutral-400">Loading...</div> <div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : ( ) : (
<DataTable <DataTable
data={permissions} data={permissions}
columns={columns} columns={columns}
pageSize={9}
table={table} table={table}
manualPagination
pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/> />
)} )}
</section> </CardContent>
</main> </Card>
</Fragment>
) <DeleteConfirmDialog
open={!!deleteId}
onOpenChange={(o) => !o && setDeleteId(null)}
onConfirm={() => deleteId && handleDelete(deleteId)}
title="Hapus permission ini?"
/>
</BackofficeWrapper>
);
} }
@@ -1,13 +1,22 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router';
import * as React from 'react' import * as React from 'react';
import { FC, Fragment, ReactElement, useState } from 'react' import { Filter as FilterIcon, Search, ClipboardCheck } from 'lucide-react';
import { import {
FilterOutlined, Badge,
SearchOutlined, Button,
AuditOutlined, Card,
} from '@ant-design/icons' CardContent,
import { Button, Input } from '@imphnen-frontend-service/ui/atoms' CardHeader,
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms' Input,
Popover,
PopoverContent,
PopoverTrigger,
} from '@imphnen-frontend-service/ui/atoms';
import {
DataTable,
Filter,
BackofficeWrapper,
} from '@imphnen-frontend-service/ui/organisms';
import { import {
ColumnDef, ColumnDef,
getCoreRowModel, getCoreRowModel,
@@ -15,19 +24,23 @@ import {
PaginationState, PaginationState,
useReactTable, useReactTable,
RowSelectionState, RowSelectionState,
} from '@tanstack/react-table' } from '@tanstack/react-table';
import ModalProcessDelivery from './_components/prizes/modal-process-item' import ModalProcessDelivery from './_components/prizes/modal-process-item';
import {
SelectAllCheckbox,
RowSelectCheckbox,
} from '../../components/list-helpers';
type OrderValid = 'valid' | 'invalid' | 'unchecked' type OrderValid = 'valid' | 'invalid' | 'unchecked';
type Status = 'undelivered' | 'delivered' type Status = 'undelivered' | 'delivered';
interface Prize { interface Prize {
id: number id: number;
name: string name: string;
orderValid: OrderValid orderValid: OrderValid;
items: string items: string;
address: string address: string;
status: Status status: Status;
} }
const items = [ const items = [
@@ -37,7 +50,7 @@ const items = [
'Sticker Isi 3', 'Sticker Isi 3',
'Sticker Isi 5', 'Sticker Isi 5',
'Gelang Karet', 'Gelang Karet',
] ];
const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({ const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1, id: i + 1,
@@ -45,141 +58,108 @@ const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
orderValid: (i % 3 === 0 orderValid: (i % 3 === 0
? 'invalid' ? 'invalid'
: i % 5 === 0 : i % 5 === 0
? 'unchecked' ? 'unchecked'
: 'valid') as OrderValid, : 'valid') as OrderValid,
items: items[i % items.length], items: items[i % items.length],
address: 'Jl. Pantai Cibaduyut Indah', address: 'Jl. Pantai Cibaduyut Indah',
status: (i % 3 === 0 ? 'undelivered' : 'delivered') as Status, status: (i % 3 === 0 ? 'undelivered' : 'delivered') as Status,
})) }));
export const Route = createFileRoute('/_authenticated/prizes')({ export const Route = createFileRoute('/_authenticated/prizes')({
component: PrizesPage, component: PrizesPage,
}) });
function PrizesPage() { function PrizesPage() {
const [showModalProcessDelivery, setShowModalProcessDelivery] = const [showModalProcessDelivery, setShowModalProcessDelivery] =
useState(false) React.useState(false);
const [pagination, setPagination] = React.useState<PaginationState>({ const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 10,
}) });
const [rowSelection, setRowSelection] =
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({}) React.useState<RowSelectionState>({});
const [showFilter, setShowFilter] = useState(false) const [showFilter, setShowFilter] = React.useState(false);
const deliveryOptions = [ const deliveryOptions = [
{ id: 'option1', value: 'undelivered', label: 'Undelivered' }, { id: 'undelivered', value: 'undelivered', label: 'Undelivered' },
{ id: 'option1', value: 'delivered', label: 'Delivered' }, { id: 'delivered', value: 'delivered', label: 'Delivered' },
] ];
const orderValidVariants: Record<
OrderValid,
'success' | 'destructive' | 'warning'
> = {
valid: 'success',
invalid: 'destructive',
unchecked: 'warning',
};
const orderValidText: Record<OrderValid, string> = {
valid: 'Valid',
invalid: 'Invalid',
unchecked: 'Unchecked',
};
const statusVariants: Record<Status, 'success' | 'destructive'> = {
delivered: 'success',
undelivered: 'destructive',
};
const statusText: Record<Status, string> = {
delivered: 'Delivered',
undelivered: 'Undelivered',
};
const columns: ColumnDef<Prize>[] = [ const columns: ColumnDef<Prize>[] = [
{ {
id: 'select', id: 'select',
header: ({ table }) => ( header: ({ table }) => <SelectAllCheckbox table={table} />,
<input cell: ({ row }) => <RowSelectCheckbox row={row} />,
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'No',
accessorKey: 'id',
},
{
header: 'Nama Lengkap',
accessorKey: 'name',
}, },
{ header: 'No', accessorKey: 'id' },
{ header: 'Nama Lengkap', accessorKey: 'name' },
{ {
header: 'Order Valid?', header: 'Order Valid?',
accessorKey: 'orderValid', accessorKey: 'orderValid',
cell: ({ row }) => { cell: ({ row }) => (
const status = row.original.orderValid <Badge variant={orderValidVariants[row.original.orderValid]}>
const statusColors: Record<OrderValid, string> = { {orderValidText[row.original.orderValid]}
valid: 'bg-success-200 text-success-500', </Badge>
invalid: 'bg-danger-200 text-danger-500', ),
unchecked: 'bg-warning-200 text-warning-900',
}
const statusText: Record<OrderValid, string> = {
valid: 'Valid',
invalid: 'Invalid',
unchecked: 'Unchecked',
}
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
)
},
},
{
header: 'Items',
accessorKey: 'items',
},
{
header: 'Alamat Pengiriman',
accessorKey: 'address',
}, },
{ header: 'Items', accessorKey: 'items' },
{ header: 'Alamat Pengiriman', accessorKey: 'address' },
{ {
header: 'Status', header: 'Status',
accessorKey: 'status', accessorKey: 'status',
cell: ({ row }) => { cell: ({ row }) => (
const status = row.original.status <Badge variant={statusVariants[row.original.status]}>
const statusColors: Record<Status, string> = { {statusText[row.original.status]}
delivered: 'bg-success-200 text-success-500', </Badge>
undelivered: 'bg-danger-200 text-danger-500', ),
}
const statusText: Record<Status, string> = {
delivered: 'Delivered',
undelivered: 'Undelivered',
}
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
)
},
}, },
{ {
header: 'Action', header: 'Action',
cell: ({ row }) => ( cell: () => (
<Button <Button
variant="primary" variant="secondary"
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
setShowModalProcessDelivery(true) setShowModalProcessDelivery(true);
}} }}
className="flex items-center gap-2 w-full"
> >
<AuditOutlined className="text-[16px]" /> Process <ClipboardCheck className="size-3.5" />
Process
</Button> </Button>
), ),
}, },
] ];
const table = useReactTable({ const table = useReactTable({
data: mockData, data: mockData,
columns, columns,
state: { state: { pagination, rowSelection },
pagination,
rowSelection,
},
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
@@ -187,59 +167,55 @@ function PrizesPage() {
onPaginationChange: setPagination, onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize), pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false, manualPagination: false,
}) });
return ( return (
<Fragment> <BackofficeWrapper
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8"> title="Data Pengiriman Hadiah"
<header className="bg-white py-4 px-8 rounded-lg shadow p-4"> description="Proses pengiriman hadiah ke pemenang"
<h1 className="text-p2 font-semibold">Data Pengiriman Hadiah</h1> >
</header> <Card>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md"> <CardHeader>
<div className="flex justify-between items-center gap-8 mb-2"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full"> <div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder="Cari berdasarkan nama lengkap, nomor order Shopee" className="pl-9"
className="pl-12 w-full max-h-full" 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> </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> </div>
</CardHeader>
<CardContent>
<DataTable data={mockData} columns={columns} table={table} /> <DataTable data={mockData} columns={columns} table={table} />
</section> </CardContent>
</main> </Card>
<ModalProcessDelivery <ModalProcessDelivery
isOpen={showModalProcessDelivery} isOpen={showModalProcessDelivery}
onClose={() => setShowModalProcessDelivery(false)} onClose={() => setShowModalProcessDelivery(false)}
handleProcessDelivery={() => { handleProcessDelivery={() => {
console.log('Action ketika user menekan tombol Proses Pengiriman') console.log('Action proses pengiriman');
}} }}
/> />
</Fragment> </BackofficeWrapper>
) );
} }
@@ -1,182 +1,162 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons' import * as React from 'react';
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms' import { Search, Plus, Pencil, Trash2 } from 'lucide-react';
import { BackofficeWrapper, DataTable } from '@imphnen-frontend-service/ui/organisms' import {
import { cn } from '@imphnen-frontend-service/utils' Badge,
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from '@tanstack/react-table' Button,
import { useState } from 'react' Card,
import { toast } from 'sonner' CardContent,
import { useRoadmapList, useDeleteRoadmap, TRoadmapListItem, TRoadmapStatus } from '@imphnen-frontend-service/service' CardHeader,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@imphnen-frontend-service/ui/atoms';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { cn } from '@imphnen-frontend-service/utils';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import { toast } from 'sonner';
import {
useRoadmapList,
useDeleteRoadmap,
TRoadmapListItem,
TRoadmapStatus,
} from '@imphnen-frontend-service/service';
import {
SelectAllCheckbox,
RowSelectCheckbox,
DeleteConfirmDialog,
} from '../../components/list-helpers';
export const Route = createFileRoute('/_authenticated/roadmap-dimentorin')({ export const Route = createFileRoute('/_authenticated/roadmap-dimentorin')({
component: RoadmapDimentorinPage, component: RoadmapDimentorinPage,
}) });
function RoadmapDimentorinPage(): React.ReactElement { function RoadmapDimentorinPage() {
const navigate = useNavigate() const navigate = useNavigate();
const [search, setSearch] = useState('') const [search, setSearch] = React.useState('');
const [statusFilter, setStatusFilter] = useState('') const [statusFilter, setStatusFilter] = React.useState('all');
const [deletingId, setDeletingId] = useState<string | null>(null) const [deletingId, setDeletingId] = React.useState<string | null>(null);
const [rowSelection, setRowSelection] =
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) React.useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({ const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 10,
}) });
const { data: roadmapData, isLoading } = useRoadmapList() const { data: roadmapData, isLoading } = useRoadmapList();
const deleteRoadmap = useDeleteRoadmap() const deleteRoadmap = useDeleteRoadmap();
const allItems: TRoadmapListItem[] = roadmapData ?? [] const allItems: TRoadmapListItem[] = roadmapData ?? [];
const filteredItems = allItems.filter((item) => { const filteredItems = allItems.filter((item) => {
const matchSearch = !search || item.title.toLowerCase().includes(search.toLowerCase()) const matchSearch =
const matchStatus = !statusFilter || item.status === statusFilter !search || item.title.toLowerCase().includes(search.toLowerCase());
return matchSearch && matchStatus const matchStatus = statusFilter === 'all' || item.status === statusFilter;
}) return matchSearch && matchStatus;
});
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { try {
await deleteRoadmap.mutateAsync(id) await deleteRoadmap.mutateAsync(id);
toast.success('Roadmap berhasil dihapus') toast.success('Roadmap berhasil dihapus');
setDeletingId(null) setDeletingId(null);
} catch (error) { } catch (error) {
console.log(error) console.log(error);
toast.error('Gagal menghapus roadmap') toast.error('Gagal menghapus roadmap');
} }
} };
const statusColors: Record<TRoadmapStatus, string> = { const statusVariants: Record<
upcoming: 'bg-warning-200 text-warning-700', TRoadmapStatus,
in_progress: 'bg-primary-200 text-primary-700', 'warning' | 'info' | 'success'
completed: 'bg-success-200 text-success-500', > = {
} upcoming: 'warning',
in_progress: 'info',
completed: 'success',
};
const statusText: Record<TRoadmapStatus, string> = { const statusText: Record<TRoadmapStatus, string> = {
upcoming: 'Upcoming', upcoming: 'Upcoming',
in_progress: 'In Progress', in_progress: 'In Progress',
completed: 'Completed', completed: 'Completed',
} };
const columns: ColumnDef<TRoadmapListItem>[] = [ const columns: ColumnDef<TRoadmapListItem>[] = [
{ {
id: 'select', id: 'select',
meta: { cellClassName: cn('w-20') }, meta: { cellClassName: cn('w-10') },
header: ({ table }) => ( header: ({ table }) => <SelectAllCheckbox table={table} />,
<input cell: ({ row }) => <RowSelectCheckbox row={row} />,
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'title',
header: 'Title',
accessorKey: 'title',
}, },
{ id: 'title', header: 'Title', accessorKey: 'title' },
{ {
id: 'description', id: 'description',
header: 'Description', header: 'Description',
accessorKey: 'description', accessorKey: 'description',
cell: ({ row }) => ( cell: ({ row }) => (
<span className="line-clamp-2">{row.original.description}</span> <span className="line-clamp-2 max-w-md">{row.original.description}</span>
), ),
}, },
{ {
id: 'status', id: 'status',
header: 'Status', header: 'Status',
accessorKey: 'status', accessorKey: 'status',
cell: ({ row }) => { cell: ({ row }) => (
const status = row.original.status <Badge variant={statusVariants[row.original.status] ?? 'secondary'}>
return ( {statusText[row.original.status] ?? row.original.status}
<div className={`py-2 px-4 rounded-md text-center ${statusColors[status] ?? 'bg-neutral-200 text-neutral-700'}`}> </Badge>
{statusText[status] ?? status} ),
</div>
)
},
},
{
id: 'votes',
header: 'Votes',
accessorKey: 'votes',
}, },
{ id: 'votes', header: 'Votes', accessorKey: 'votes' },
{ {
header: 'Action', header: 'Action',
meta: { cellClassName: cn('w-72') },
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex gap-[8px]"> <div className="flex items-center gap-2">
{deletingId === row.original.id ? ( <Button
<> variant="secondary"
<Button size="sm"
variant="danger" onClick={(e) => {
size="sm" e.stopPropagation();
onClick={(e) => { navigate({
e.stopPropagation() to: '/roadmap-dimentorin/$id',
handleDelete(row.original.id) params: { id: row.original.id },
}} });
className="flex items-center gap-2" }}
> >
Konfirmasi <Pencil className="size-3.5" />
</Button> Edit
<Button </Button>
variant="bordered" <Button
size="sm" variant="danger"
onClick={(e) => { size="sm"
e.stopPropagation() onClick={(e) => {
setDeletingId(null) e.stopPropagation();
}} setDeletingId(row.original.id);
className="flex items-center gap-2" }}
> >
Batal <Trash2 className="size-3.5" />
</Button> </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> </div>
), ),
}, },
] ];
const table = useReactTable({ const table = useReactTable({
data: filteredItems, data: filteredItems,
columns, columns,
state: { state: { pagination, rowSelection },
pagination,
rowSelection,
},
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
@@ -184,51 +164,64 @@ function RoadmapDimentorinPage(): React.ReactElement {
onPaginationChange: setPagination, onPaginationChange: setPagination,
pageCount: Math.ceil(filteredItems.length / pagination.pageSize), pageCount: Math.ceil(filteredItems.length / pagination.pageSize),
manualPagination: false, manualPagination: false,
}) });
return ( return (
<BackofficeWrapper title="Dimentorin.dev"> <BackofficeWrapper
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Content & Roadmap</h1> title="Content & Roadmap"
description="Kelola AI roadmap dimentorin"
<section className="flex flex-col gap-6 p-8 bg-white rounded-md"> >
<div className="flex items-center justify-between mb-9"> <Card>
<h2 className="text-p2 font-semibold text-neutral-600">AI Roadmaps</h2> <CardHeader>
<Button <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
type="button" <div className="flex flex-1 flex-col gap-2 sm:flex-row">
variant="primary" <div className="relative w-full sm:max-w-sm">
className="flex items-center gap-2" <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
onClick={() => navigate({ to: '/roadmap-dimentorin/create' })} <Input
> className="pl-9"
<PlusOutlined /> Buat Roadmap placeholder="Cari judul roadmap…"
</Button> value={search}
</div> onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex justify-between items-center gap-5 mb-2"> </div>
<div className="relative w-full"> <Select value={statusFilter} onValueChange={setStatusFilter}>
<Input <SelectTrigger className="w-40">
placeholder="Cari berdasarkan judul roadmap" <SelectValue placeholder="Status" />
className="pl-12 w-full max-h-full" </SelectTrigger>
value={search} <SelectContent>
onChange={(e) => setSearch(e.target.value)} <SelectItem value="all">Semua Status</SelectItem>
/> <SelectItem value="upcoming">Upcoming</SelectItem>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]"> <SelectItem value="in_progress">In Progress</SelectItem>
<SearchOutlined /> <SelectItem value="completed">Completed</SelectItem>
</SelectContent>
</Select>
</div> </div>
<Button
size="md"
onClick={() => navigate({ to: '/roadmap-dimentorin/create' })}
>
<Plus className="size-4" />
Buat Roadmap
</Button>
</div> </div>
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}> </CardHeader>
<option value="">Semua Status</option> <CardContent>
<option value="upcoming">Upcoming</option> {isLoading ? (
<option value="in_progress">In Progress</option> <div className="py-10 text-center text-sm text-muted-foreground">
<option value="completed">Completed</option> Memuat data…
</Select> </div>
</div> ) : (
<DataTable data={filteredItems} columns={columns} table={table} />
)}
</CardContent>
</Card>
{isLoading ? ( <DeleteConfirmDialog
<div className="text-center py-8 text-neutral-400">Loading...</div> open={!!deletingId}
) : ( onOpenChange={(o) => !o && setDeletingId(null)}
<DataTable data={filteredItems} columns={columns} table={table} /> onConfirm={() => deletingId && handleDelete(deletingId)}
)} title="Hapus roadmap ini?"
</section> />
</BackofficeWrapper> </BackofficeWrapper>
) );
} }
@@ -1,13 +1,17 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { Fragment, useState } from 'react' import * as React from 'react';
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
import { import {
SearchOutlined, Button,
EditOutlined, Card,
DeleteOutlined, CardContent,
PlusOutlined, CardHeader,
} from '@ant-design/icons' Input,
import { Button, Input } from '@imphnen-frontend-service/ui/atoms' } from '@imphnen-frontend-service/ui/atoms';
import { DataTable } from '@imphnen-frontend-service/ui/organisms' import {
DataTable,
BackofficeWrapper,
} from '@imphnen-frontend-service/ui/organisms';
import { import {
ColumnDef, ColumnDef,
getCoreRowModel, getCoreRowModel,
@@ -15,144 +19,98 @@ import {
PaginationState, PaginationState,
RowSelectionState, RowSelectionState,
useReactTable, useReactTable,
} from '@tanstack/react-table' } from '@tanstack/react-table';
import { import {
useRoleList, useRoleList,
useDeleteRole, useDeleteRole,
TRolesListItem, TRolesListItem,
} from '@imphnen-frontend-service/service' } from '@imphnen-frontend-service/service';
import React from 'react' import { toast } from 'sonner';
import { toast } from 'sonner' import {
SelectAllCheckbox,
RowSelectCheckbox,
DeleteConfirmDialog,
} from '../../components/list-helpers';
export const Route = createFileRoute('/_authenticated/roles')({ export const Route = createFileRoute('/_authenticated/roles')({
component: RolesPage, component: RolesPage,
}) });
function RolesPage() { function RolesPage() {
const navigate = useNavigate() const navigate = useNavigate();
const [search, setSearch] = useState('') const [search, setSearch] = React.useState('');
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = React.useState<string | null>(null);
const [pagination, setPagination] = React.useState<PaginationState>({ const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 10,
}) });
const [rowSelection, setRowSelection] =
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({}) React.useState<RowSelectionState>({});
const { data: rolesData, isLoading } = useRoleList({ const { data: rolesData, isLoading } = useRoleList({
search, search,
page: pagination.pageIndex + 1, page: pagination.pageIndex + 1,
per_page: pagination.pageSize, per_page: pagination.pageSize,
}) });
const deleteRole = useDeleteRole() const deleteRole = useDeleteRole();
const roles: TRolesListItem[] = rolesData?.data ?? [] const roles: TRolesListItem[] = rolesData?.data ?? [];
const totalItems = rolesData?.meta?.total ?? roles.length const totalItems = rolesData?.meta?.total ?? roles.length;
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { try {
await deleteRole.mutateAsync(id) await deleteRole.mutateAsync(id);
toast.success('Data role berhasil dihapus') toast.success('Data role berhasil dihapus');
setDeleteId(null) setDeleteId(null);
} catch (error) { } catch (error) {
console.log(error) console.log(error);
toast.error('Data role gagal dihapus') toast.error('Data role gagal dihapus');
} }
} };
const columns: ColumnDef<TRolesListItem>[] = [ const columns: ColumnDef<TRolesListItem>[] = [
{ {
id: 'select', id: 'select',
header: ({ table }) => ( header: ({ table }) => <SelectAllCheckbox table={table} />,
<input cell: ({ row }) => <RowSelectCheckbox row={row} />,
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'ID',
accessorKey: 'id',
},
{
header: 'Roles Name',
accessorKey: 'name',
}, },
{ header: 'ID', accessorKey: 'id' },
{ header: 'Roles Name', accessorKey: 'name' },
{ {
header: 'Action', header: 'Action',
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex gap-[8px]"> <div className="flex items-center gap-2">
<Button <Button
variant="primary" variant="secondary"
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
navigate({ to: '/roles/$id', params: { id: row.original.id } }) 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> </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> </div>
), ),
}, },
] ];
const table = useReactTable({ const table = useReactTable({
data: roles, data: roles,
columns, columns,
state: { state: { pagination, rowSelection },
pagination,
rowSelection,
},
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
@@ -160,53 +118,58 @@ function RolesPage() {
onPaginationChange: setPagination, onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize), pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true, manualPagination: true,
}) });
return ( return (
<Fragment> <BackofficeWrapper title="Roles" description="Kelola role dan akses">
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8"> <Card>
<header className="bg-white py-4 px-8 rounded-lg shadow p-4"> <CardHeader>
<h1 className="text-p2 font-semibold">Roles</h1> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
</header> <div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<Input <Input
placeholder="Cari berdasarkan nama roles" placeholder="Cari nama role…"
className="pl-12 w-full max-h-full" className="pl-9"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex gap-3 text-nowrap"
onClick={() => navigate({ to: '/roles/create' })}
>
<PlusOutlined />
Tambah Role
</Button>
</div> </div>
<Button
onClick={() => navigate({ to: '/roles/create' })}
size="md"
>
<Plus className="size-4" />
Tambah Role
</Button>
</div> </div>
</CardHeader>
<CardContent>
{isLoading ? ( {isLoading ? (
<div className="text-center py-8 text-neutral-400">Loading...</div> <div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : ( ) : (
<DataTable <DataTable
data={roles} data={roles}
columns={columns} columns={columns}
pageSize={9}
table={table} table={table}
manualPagination
pageCount={Math.ceil(totalItems / pagination.pageSize)}
currentPage={pagination.pageIndex + 1}
onPageChange={(p) =>
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/> />
)} )}
</section> </CardContent>
</main> </Card>
</Fragment>
) <DeleteConfirmDialog
open={!!deleteId}
onOpenChange={(o) => !o && setDeleteId(null)}
onConfirm={() => deleteId && handleDelete(deleteId)}
title="Hapus role ini?"
/>
</BackofficeWrapper>
);
} }
@@ -1,64 +1,82 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { SearchOutlined } from '@ant-design/icons' import * as React from 'react';
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms' import { Search, Eye } from 'lucide-react';
import { BackofficeWrapper, DataTable } from '@imphnen-frontend-service/ui/organisms' import {
import { cn } from '@imphnen-frontend-service/utils' Badge,
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from '@tanstack/react-table' Button,
import { ReactElement, useState } from 'react' Card,
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service' CardContent,
CardHeader,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@imphnen-frontend-service/ui/atoms';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { cn } from '@imphnen-frontend-service/utils';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import {
useMySessions,
TSessionListItem,
} from '@imphnen-frontend-service/service';
import {
SelectAllCheckbox,
RowSelectCheckbox,
} from '../../components/list-helpers';
export const Route = createFileRoute('/_authenticated/session-dimentorin')({ export const Route = createFileRoute('/_authenticated/session-dimentorin')({
component: SessionDimentorinPage, component: SessionDimentorinPage,
}) });
function SessionDimentorinPage(): ReactElement { function SessionDimentorinPage() {
const navigate = useNavigate() const navigate = useNavigate();
const [statusFilter, setStatusFilter] = useState('') const [statusFilter, setStatusFilter] = React.useState('all');
const [rowSelection, setRowSelection] =
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) React.useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({ const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 10,
}) });
const { data: sessionsData, isLoading } = useMySessions( const { data: sessionsData, isLoading } = useMySessions(
statusFilter ? { status: statusFilter } : undefined statusFilter !== 'all' ? { status: statusFilter } : undefined
) );
const sessions: TSessionListItem[] = sessionsData?.sessions ?? [] const sessions: TSessionListItem[] = sessionsData?.sessions ?? [];
const totalItems = sessionsData?.total ?? sessions.length const totalItems = sessionsData?.total ?? sessions.length;
const statusVariants: Record<
string,
'warning' | 'info' | 'success' | 'destructive' | 'secondary'
> = {
pending: 'warning',
confirmed: 'info',
ongoing: 'warning',
completed: 'success',
cancelled: 'destructive',
};
const columns: ColumnDef<TSessionListItem>[] = [ const columns: ColumnDef<TSessionListItem>[] = [
{ {
id: 'select', id: 'select',
meta: { cellClassName: cn('w-20') }, meta: { cellClassName: cn('w-10') },
header: ({ table }) => ( header: ({ table }) => <SelectAllCheckbox table={table} />,
<input cell: ({ row }) => <RowSelectCheckbox row={row} />,
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'id',
header: 'ID Sesi',
accessorKey: 'id',
},
{
id: 'mentorId',
header: 'Nama Mentor',
accessorKey: 'mentor_id',
}, },
{ id: 'id', header: 'ID Sesi', accessorKey: 'id' },
{ id: 'mentorId', header: 'Nama Mentor', accessorKey: 'mentor_id' },
{ {
id: 'menteeName', id: 'menteeName',
header: 'Nama Mentee', header: 'Nama Mentee',
@@ -69,55 +87,49 @@ function SessionDimentorinPage(): ReactElement {
header: 'Waktu', header: 'Waktu',
accessorKey: 'scheduled_at', accessorKey: 'scheduled_at',
cell: ({ row }) => ( cell: ({ row }) => (
<span>{new Date(row.original.scheduled_at).toLocaleString('id-ID')}</span> <span>
{new Date(row.original.scheduled_at).toLocaleString('id-ID')}
</span>
), ),
}, },
{ {
id: 'status', id: 'status',
header: 'Status', header: 'Status',
accessorKey: 'status', accessorKey: 'status',
cell: ({ row }) => { cell: ({ row }) => (
const status = row.original.status <Badge
const statusColors: Record<string, string> = { variant={statusVariants[row.original.status] ?? 'secondary'}
pending: 'bg-warning-200 text-warning-700', className="capitalize"
confirmed: 'bg-primary-200 text-primary-700', >
ongoing: 'bg-warning-200 text-warning-700', {row.original.status}
completed: 'bg-success-200 text-success-500', </Badge>
cancelled: 'bg-danger-200 text-danger-500', ),
}
return (
<div className={`py-2 px-4 rounded-md text-center capitalize ${statusColors[status] ?? 'bg-neutral-200 text-neutral-700'}`}>
{status}
</div>
)
},
}, },
{ {
header: 'Action', header: 'Action',
meta: { cellClassName: cn('w-52') },
cell: ({ row }) => ( cell: ({ row }) => (
<Button <Button
variant="primary" variant="secondary"
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
navigate({ to: '/session-dimentorin/$id', params: { id: row.original.id } }) navigate({
to: '/session-dimentorin/$id',
params: { id: row.original.id },
});
}} }}
className="flex items-center gap-2 w-max"
> >
<SearchOutlined className="text-[16px]" /> Cek Detail <Eye className="size-3.5" />
Detail
</Button> </Button>
), ),
}, },
] ];
const table = useReactTable({ const table = useReactTable({
data: sessions, data: sessions,
columns, columns,
state: { state: { pagination, rowSelection },
pagination,
rowSelection,
},
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
@@ -125,39 +137,55 @@ function SessionDimentorinPage(): ReactElement {
onPaginationChange: setPagination, onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize), pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true, manualPagination: true,
}) });
return ( return (
<BackofficeWrapper title="Dimentorin.dev"> <BackofficeWrapper
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Session Management</h1> title="Session Management"
description="Kelola sesi mentoring aktif"
<section className="flex flex-col gap-6 p-8 bg-white rounded-md"> >
<div className="flex justify-between items-center gap-5 mb-2"> <Card>
<div className="relative w-full"> <CardHeader>
<Input <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
placeholder="Cari berdasarkan nama lengkap" <div className="relative w-full sm:max-w-sm">
className="pl-12 w-full max-h-full" <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
/> <Input className="pl-9" placeholder="Cari nama lengkap…" />
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div> </div>
<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> </div>
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}> </CardHeader>
<option value="">Semua Status</option> <CardContent>
<option value="pending">Pending</option> {isLoading ? (
<option value="confirmed">Confirmed</option> <div className="py-10 text-center text-sm text-muted-foreground">
<option value="ongoing">On Going</option> Memuat data…
<option value="completed">Completed</option> </div>
<option value="cancelled">Cancelled</option> ) : (
</Select> <DataTable
</div> data={sessions}
columns={columns}
{isLoading ? ( table={table}
<div className="text-center py-8 text-neutral-400">Loading...</div> manualPagination
) : ( pageCount={Math.ceil(totalItems / pagination.pageSize)}
<DataTable data={sessions} columns={columns} table={table} /> currentPage={pagination.pageIndex + 1}
)} onPageChange={(p) =>
</section> setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
}
/>
)}
</CardContent>
</Card>
</BackofficeWrapper> </BackofficeWrapper>
) );
} }
@@ -1,6 +1,6 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { ArrowLeftOutlined } from '@ant-design/icons' import { ArrowLeftOutlined } from '@ant-design/icons'
import { Input, Select, Textarea } from '@imphnen-frontend-service/ui/atoms' import { Input, NativeSelect as Select, Textarea } from '@imphnen-frontend-service/ui/atoms'
import { cn, For } from '@imphnen-frontend-service/utils' import { cn, For } from '@imphnen-frontend-service/utils'
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service' import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service'
@@ -1,58 +1,74 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router';
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms' import * as React from 'react';
import { cn, For } from '@imphnen-frontend-service/utils' import {
import { useState } from 'react' Card,
import { GeneralSettings } from './_components/settings-dimentorin/general' CardContent,
import { UserRolesPermission } from './_components/settings-dimentorin/user-roles-permission' Tabs,
import { NotificationSettings } from './_components/settings-dimentorin/notification' TabsContent,
import { SecuritySettings } from './_components/settings-dimentorin/security' TabsList,
import { PaymentSettings } from './_components/settings-dimentorin/payment' TabsTrigger,
} from '@imphnen-frontend-service/ui/atoms';
const TABS = { import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
general: 'General Settings', import { GeneralSettings } from './_components/settings-dimentorin/general';
userRolePermissions: 'User Roles & Permissions', import { UserRolesPermission } from './_components/settings-dimentorin/user-roles-permission';
notification: 'Notification Settings', import { NotificationSettings } from './_components/settings-dimentorin/notification';
security: 'Security', import { SecuritySettings } from './_components/settings-dimentorin/security';
payment: 'Payment', import { PaymentSettings } from './_components/settings-dimentorin/payment';
} as const
type Tabs = typeof TABS[keyof typeof TABS]
export const Route = createFileRoute('/_authenticated/settings-dimentorin')({ export const Route = createFileRoute('/_authenticated/settings-dimentorin')({
component: SettingsDimentorinPage, component: SettingsDimentorinPage,
}) });
function SettingsDimentorinPage(): React.ReactElement { function SettingsDimentorinPage(): React.ReactElement {
const [activeTab, setActiveTab] = useState<Tabs>(TABS.general)
return ( return (
<BackofficeWrapper title="Dimentorin.dev"> <BackofficeWrapper
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Settings</h1> title="Dimentorin Settings"
description="Konfigurasi platform Dimentorin.dev"
<div className="flex items-start gap-x-8"> >
<div className="w-64 bg-white p-2.5 shadow space-y-2 rounded-md"> <Tabs defaultValue="general" className="gap-6">
<For data={Object.values(TABS)}> <TabsList className="flex-wrap">
{(tab) => ( <TabsTrigger value="general">General</TabsTrigger>
<button <TabsTrigger value="roles">Roles & Permissions</TabsTrigger>
key={tab} <TabsTrigger value="notification">Notification</TabsTrigger>
className={cn( <TabsTrigger value="security">Security</TabsTrigger>
'px-4 py-3 w-full text-left font-medium rounded-md text-neutral-400 cursor-pointer select-none hover:bg-primary-100', <TabsTrigger value="payment">Payment</TabsTrigger>
activeTab === tab && 'bg-primary-500 text-white hover:bg-primary-600' </TabsList>
)} <TabsContent value="general">
onClick={() => setActiveTab(tab)} <Card>
> <CardContent className="pt-6">
{tab} <GeneralSettings />
</button> </CardContent>
)} </Card>
</For> </TabsContent>
</div> <TabsContent value="roles">
<div className="bg-white px-8 py-6 shadow space-y-2 rounded-md flex-1"> <Card>
{activeTab === TABS.general && <GeneralSettings />} <CardContent className="pt-6">
{activeTab === TABS.userRolePermissions && <UserRolesPermission />} <UserRolesPermission />
{activeTab === TABS.notification && <NotificationSettings />} </CardContent>
{activeTab === TABS.security && <SecuritySettings />} </Card>
{activeTab === TABS.payment && <PaymentSettings />} </TabsContent>
</div> <TabsContent value="notification">
</div> <Card>
<CardContent className="pt-6">
<NotificationSettings />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="security">
<Card>
<CardContent className="pt-6">
<SecuritySettings />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="payment">
<Card>
<CardContent className="pt-6">
<PaymentSettings />
</CardContent>
</Card>
</TabsContent>
</Tabs>
</BackofficeWrapper> </BackofficeWrapper>
) );
} }
@@ -1,13 +1,22 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router';
import * as React from 'react' import * as React from 'react';
import { FC, Fragment, ReactElement, useState } from 'react' import { Filter as FilterIcon, Search, ClipboardCheck } from 'lucide-react';
import { import {
FilterOutlined, Badge,
SearchOutlined, Button,
AuditOutlined, Card,
} from '@ant-design/icons' CardContent,
import { Button, Input } from '@imphnen-frontend-service/ui/atoms' CardHeader,
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms' Input,
Popover,
PopoverContent,
PopoverTrigger,
} from '@imphnen-frontend-service/ui/atoms';
import {
DataTable,
Filter,
BackofficeWrapper,
} from '@imphnen-frontend-service/ui/organisms';
import { import {
ColumnDef, ColumnDef,
getCoreRowModel, getCoreRowModel,
@@ -15,131 +24,111 @@ import {
PaginationState, PaginationState,
useReactTable, useReactTable,
RowSelectionState, RowSelectionState,
} from '@tanstack/react-table' } from '@tanstack/react-table';
import ModalValidate from './_components/transactions/modal-validate' import ModalValidate from './_components/transactions/modal-validate';
import {
SelectAllCheckbox,
RowSelectCheckbox,
} from '../../components/list-helpers';
type TransactionStatus = 'valid' | 'invalid' | 'unchecked' type TransactionStatus = 'valid' | 'invalid' | 'unchecked';
interface Transaction { interface Transaction {
id: number id: number;
name: string name: string;
transactionNumber: string transactionNumber: string;
status: TransactionStatus status: TransactionStatus;
} }
const mockTransactions: Transaction[] = Array.from({ length: 20 }, (_, i) => ({ const mockTransactions: Transaction[] = Array.from(
id: i + 1, { length: 20 },
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap', (_, i) => ({
transactionNumber: '25D2133Y9AFYBD', id: i + 1,
status: (i % 3 === 0 name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
? 'invalid' transactionNumber: '25D2133Y9AFYBD',
: i % 5 === 0 status: (i % 3 === 0
? 'unchecked' ? 'invalid'
: 'valid') as TransactionStatus, : i % 5 === 0
})) ? 'unchecked'
: 'valid') as TransactionStatus,
})
);
export const Route = createFileRoute('/_authenticated/transactions')({ export const Route = createFileRoute('/_authenticated/transactions')({
component: TransactionsPage, component: TransactionsPage,
}) });
function TransactionsPage() { function TransactionsPage() {
const [showModalValidate, setShowModalValidate] = useState(false) const [showModalValidate, setShowModalValidate] = React.useState(false);
const [pagination, setPagination] = React.useState<PaginationState>({ const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 10,
}) });
const [rowSelection, setRowSelection] =
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({}) React.useState<RowSelectionState>({});
const [showFilter, setShowFilter] = useState(false) const [showFilter, setShowFilter] = React.useState(false);
const validationOptions = [ const validationOptions = [
{ id: 'option1', value: 'unchecked', label: 'Unchecked' }, { id: 'unchecked', value: 'unchecked', label: 'Unchecked' },
{ id: 'option2', value: 'valid', label: 'Valid' }, { id: 'valid', value: 'valid', label: 'Valid' },
{ id: 'option3', value: 'invalid', label: 'Invalid' }, { id: 'invalid', value: 'invalid', label: 'Invalid' },
] ];
const statusVariants: Record<
TransactionStatus,
'success' | 'destructive' | 'warning'
> = {
valid: 'success',
invalid: 'destructive',
unchecked: 'warning',
};
const statusText: Record<TransactionStatus, string> = {
valid: 'Valid',
invalid: 'Invalid',
unchecked: 'Unchecked',
};
const columns: ColumnDef<Transaction>[] = [ const columns: ColumnDef<Transaction>[] = [
{ {
id: 'select', id: 'select',
header: ({ table }) => ( header: ({ table }) => <SelectAllCheckbox table={table} />,
<input cell: ({ row }) => <RowSelectCheckbox row={row} />,
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'No',
accessorKey: 'id',
},
{
header: 'Nama Lengkap',
accessorKey: 'name',
},
{
header: 'Nomor Transaksi',
accessorKey: 'transactionNumber',
}, },
{ header: 'No', accessorKey: 'id' },
{ header: 'Nama Lengkap', accessorKey: 'name' },
{ header: 'Nomor Transaksi', accessorKey: 'transactionNumber' },
{ {
header: 'Order Valid?', header: 'Order Valid?',
accessorKey: 'status', accessorKey: 'status',
cell: ({ row }) => { cell: ({ row }) => (
const status = row.original.status <Badge variant={statusVariants[row.original.status]}>
const statusColors: Record<TransactionStatus, string> = { {statusText[row.original.status]}
valid: 'bg-success-200 text-success-500', </Badge>
invalid: 'bg-danger-200 text-danger-500', ),
unchecked: 'bg-warning-200 text-warning-900',
}
const statusText: Record<TransactionStatus, string> = {
valid: 'Valid',
invalid: 'Invalid',
unchecked: 'Unchecked',
}
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
)
},
}, },
{ {
header: 'Action', header: 'Action',
cell: ({ row }) => ( cell: () => (
<Button <Button
variant="primary" variant="secondary"
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
setShowModalValidate(true) setShowModalValidate(true);
}} }}
className="flex items-center gap-2 w-full"
> >
<AuditOutlined className="text-[16px]" /> Update <ClipboardCheck className="size-3.5" />
Update
</Button> </Button>
), ),
}, },
] ];
const table = useReactTable({ const table = useReactTable({
data: mockTransactions, data: mockTransactions,
columns, columns,
state: { state: { pagination, rowSelection },
pagination,
rowSelection,
},
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
@@ -147,64 +136,58 @@ function TransactionsPage() {
onPaginationChange: setPagination, onPaginationChange: setPagination,
pageCount: Math.ceil(mockTransactions.length / pagination.pageSize), pageCount: Math.ceil(mockTransactions.length / pagination.pageSize),
manualPagination: false, manualPagination: false,
}) });
return ( return (
<Fragment> <BackofficeWrapper
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8"> title="Validasi Transaksi"
<header className="bg-white py-4 px-8 rounded-lg shadow p-4"> description="Verifikasi status transaksi pengguna"
<h1 className="text-p2 font-semibold">Validasi Transaksi</h1> >
</header> <Card>
<CardHeader>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex justify-between items-center gap-8 mb-2"> <div className="relative w-full sm:max-w-sm">
<div className="relative w-full"> <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder="Cari berdasarkan nama lengkap, nomor order Shopee" className="pl-9"
className="pl-12 w-full max-h-full" 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> </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> </div>
</CardHeader>
<CardContent>
<DataTable data={mockTransactions} columns={columns} table={table} /> <DataTable data={mockTransactions} columns={columns} table={table} />
</section> </CardContent>
</main> </Card>
<ModalValidate <ModalValidate
isOpen={showModalValidate} isOpen={showModalValidate}
onClose={() => setShowModalValidate(false)} onClose={() => setShowModalValidate(false)}
handleValid={() => { handleValid={() => {
console.log('Action ketika user klik Valid') console.log('Action ketika user klik Valid');
}} }}
handleInvalid={() => { handleInvalid={() => {
console.log('Action ketika user klik Tidak Valid') console.log('Action ketika user klik Tidak Valid');
}} }}
/> />
</Fragment> </BackofficeWrapper>
) );
} }
@@ -1,11 +1,23 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { DeleteOutlined, SearchOutlined } from '@ant-design/icons' import * as React from 'react';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms' import { Eye, Search, Trash2 } from 'lucide-react';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
Input,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@imphnen-frontend-service/ui/atoms';
import { import {
BackofficeWrapper, BackofficeWrapper,
DataTable, DataTable,
} from '@imphnen-frontend-service/ui/organisms' } from '@imphnen-frontend-service/ui/organisms';
import { cn, For } from '@imphnen-frontend-service/utils' import { cn } from '@imphnen-frontend-service/utils';
import { import {
ColumnDef, ColumnDef,
getCoreRowModel, getCoreRowModel,
@@ -13,98 +25,85 @@ import {
PaginationState, PaginationState,
RowSelectionState, RowSelectionState,
useReactTable, useReactTable,
} from '@tanstack/react-table' } from '@tanstack/react-table';
import { ReactElement, useState } from 'react' import { toast } from 'sonner';
import { toast } from 'sonner'
import { import {
useMentorList, useMentorList,
useUserList, useUserList,
useDeleteMentor, useDeleteMentor,
MentorDetailResponseDto, MentorDetailResponseDto,
TUsersListItem, TUsersListItem,
} from '@imphnen-frontend-service/service' } from '@imphnen-frontend-service/service';
import {
SelectAllCheckbox,
RowSelectCheckbox,
DeleteConfirmDialog,
} from '../../components/list-helpers';
export const Route = createFileRoute('/_authenticated/users-dimentorin')({ export const Route = createFileRoute('/_authenticated/users-dimentorin')({
component: UsersDimentorinPage, component: UsersDimentorinPage,
}) });
function UsersDimentorinPage(): ReactElement { function UsersDimentorinPage() {
const TABS = ['mentor', 'mentee'] as const const navigate = useNavigate();
const navigate = useNavigate() const [activeTab, setActiveTab] = React.useState<'mentor' | 'mentee'>(
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor') 'mentor'
const [search, setSearch] = useState('') );
const [deletingId, setDeletingId] = useState<string | null>(null) const [search, setSearch] = React.useState('');
const [deletingId, setDeletingId] = React.useState<string | null>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) const [rowSelection, setRowSelection] =
const [pagination, setPagination] = useState<PaginationState>({ React.useState<RowSelectionState>({});
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 10,
}) });
const { data: mentorData, isLoading: mentorLoading } = useMentorList({ const { data: mentorData, isLoading: mentorLoading } = useMentorList({
search, search,
page: pagination.pageIndex + 1, page: pagination.pageIndex + 1,
per_page: pagination.pageSize, per_page: pagination.pageSize,
}) });
const { data: menteeData, isLoading: menteeLoading } = useUserList({ const { data: menteeData, isLoading: menteeLoading } = useUserList({
search, search,
page: pagination.pageIndex + 1, page: pagination.pageIndex + 1,
per_page: pagination.pageSize, per_page: pagination.pageSize,
}) });
const deleteMentor = useDeleteMentor();
const deleteMentor = useDeleteMentor() const mentors: MentorDetailResponseDto[] = mentorData?.data ?? [];
const mentees: TUsersListItem[] = menteeData?.data ?? [];
const mentors: MentorDetailResponseDto[] = mentorData?.data ?? [] const mentorTotal = mentorData?.meta?.total ?? mentors.length;
const mentees: TUsersListItem[] = menteeData?.data ?? [] const menteeTotal = menteeData?.meta?.total ?? mentees.length;
const mentorTotal = mentorData?.meta?.total ?? mentors.length
const menteeTotal = menteeData?.meta?.total ?? mentees.length
const isLoading = activeTab === 'mentor' ? mentorLoading : menteeLoading
const totalItems = activeTab === 'mentor' ? mentorTotal : menteeTotal
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { try {
await deleteMentor.mutateAsync(id) await deleteMentor.mutateAsync(id);
toast.success('Akun berhasil dihapus') toast.success('Akun berhasil dihapus');
setDeletingId(null) setDeletingId(null);
} catch (error) { } catch (error) {
console.log(error) console.log(error);
toast.error('Gagal menghapus akun') toast.error('Gagal menghapus akun');
} }
} };
const statusVariantMap: Record<
string,
'success' | 'warning' | 'destructive' | 'secondary'
> = {
active: 'success',
pending: 'warning',
inactive: 'destructive',
};
const mentorColumns: ColumnDef<MentorDetailResponseDto>[] = [ const mentorColumns: ColumnDef<MentorDetailResponseDto>[] = [
{ {
id: 'select', id: 'select',
meta: { cellClassName: cn('w-20') }, meta: { cellClassName: cn('w-10') },
header: ({ table }) => ( header: ({ table }) => <SelectAllCheckbox table={table} />,
<input cell: ({ row }) => <RowSelectCheckbox row={row} />,
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'name',
header: 'Name',
accessorKey: 'fullname',
},
{
id: 'email',
header: 'Email',
accessorKey: 'email',
}, },
{ id: 'name', header: 'Name', accessorKey: 'fullname' },
{ id: 'email', header: 'Email', accessorKey: 'email' },
{ {
id: 'rating', id: 'rating',
header: 'Rating', header: 'Rating',
@@ -116,139 +115,86 @@ function UsersDimentorinPage(): ReactElement {
header: 'Status', header: 'Status',
accessorKey: 'status', accessorKey: 'status',
cell: ({ row }) => { cell: ({ row }) => {
const status = row.original.status const status = row.original.status ?? '';
const statusColors: Record<string, string> = {
active: 'bg-success-200 text-success-500',
pending: 'bg-warning-200 text-warning-700',
inactive: 'bg-danger-200 text-danger-500',
}
return ( return (
<div className={`py-2 px-4 rounded-md text-center capitalize ${statusColors[status] ?? 'bg-neutral-200 text-neutral-700'}`}> <Badge variant={statusVariantMap[status] ?? 'secondary'} className="capitalize">
{status} {status || 'unknown'}
</div> </Badge>
) );
}, },
}, },
{ {
header: 'Action', header: 'Action',
meta: { cellClassName: cn('w-72') },
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex gap-[8px]"> <div className="flex items-center gap-2">
{deletingId === row.original.id ? ( <Button
<> variant="secondary"
<Button size="sm"
variant="danger" onClick={(e) => {
size="sm" e.stopPropagation();
onClick={(e) => { navigate({
e.stopPropagation() to: '/users-dimentorin/$id',
handleDelete(row.original.id) params: { id: row.original.id },
}} });
className="flex items-center gap-2" }}
> >
Konfirmasi <Eye className="size-3.5" />
</Button> Detail
<Button </Button>
variant="bordered" <Button
size="sm" variant="danger"
onClick={(e) => { size="sm"
e.stopPropagation() onClick={(e) => {
setDeletingId(null) e.stopPropagation();
}} setDeletingId(row.original.id);
className="flex items-center gap-2" }}
> >
Batal <Trash2 className="size-3.5" />
</Button> </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> </div>
), ),
}, },
] ];
const menteeColumns: ColumnDef<TUsersListItem>[] = [ const menteeColumns: ColumnDef<TUsersListItem>[] = [
{ {
id: 'select', id: 'select',
meta: { cellClassName: cn('w-20') }, meta: { cellClassName: cn('w-10') },
header: ({ table }) => ( header: ({ table }) => <SelectAllCheckbox table={table} />,
<input cell: ({ row }) => <RowSelectCheckbox row={row} />,
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'name',
header: 'Name',
accessorKey: 'fullname',
},
{
id: 'email',
header: 'Email',
accessorKey: 'email',
}, },
{ id: 'name', header: 'Name', accessorKey: 'fullname' },
{ id: 'email', header: 'Email', accessorKey: 'email' },
{ {
id: 'status', id: 'status',
header: 'Status', header: 'Status',
accessorKey: 'is_active', accessorKey: 'is_active',
cell: ({ row }) => ( cell: ({ row }) => (
<div className={`py-2 px-4 rounded-md text-center ${row.original.is_active ? 'bg-success-200 text-success-500' : 'bg-danger-200 text-danger-500'}`}> <Badge variant={row.original.is_active ? 'success' : 'destructive'}>
{row.original.is_active ? 'Active' : 'Inactive'} {row.original.is_active ? 'Active' : 'Inactive'}
</div> </Badge>
), ),
}, },
{ {
header: 'Action', header: 'Action',
meta: { cellClassName: cn('w-72') },
cell: ({ row }) => ( cell: ({ row }) => (
<Button <Button
variant="primary" variant="secondary"
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
navigate({ to: '/users-dimentorin/$id', params: { id: row.original.id } }) navigate({
to: '/users-dimentorin/$id',
params: { id: row.original.id },
});
}} }}
className="flex items-center gap-2 w-max"
> >
<SearchOutlined className="text-[16px]" /> Lihat Detail <Eye className="size-3.5" />
Detail
</Button> </Button>
), ),
}, },
] ];
const mentorTable = useReactTable({ const mentorTable = useReactTable({
data: mentors, data: mentors,
@@ -261,7 +207,7 @@ function UsersDimentorinPage(): ReactElement {
onPaginationChange: setPagination, onPaginationChange: setPagination,
pageCount: Math.ceil(mentorTotal / pagination.pageSize), pageCount: Math.ceil(mentorTotal / pagination.pageSize),
manualPagination: true, manualPagination: true,
}) });
const menteeTable = useReactTable({ const menteeTable = useReactTable({
data: mentees, data: mentees,
@@ -274,59 +220,91 @@ function UsersDimentorinPage(): ReactElement {
onPaginationChange: setPagination, onPaginationChange: setPagination,
pageCount: Math.ceil(menteeTotal / pagination.pageSize), pageCount: Math.ceil(menteeTotal / pagination.pageSize),
manualPagination: true, manualPagination: true,
}) });
return ( return (
<BackofficeWrapper title="Dimentorin.dev"> <BackofficeWrapper
<div className="mb-8 flex justify-between items-center"> title="Users Dimentorin"
<h1 className="text-p1 font-semibold text-neutral-700 mb-8"> description="Manajemen mentor dan mentee"
User Management >
</h1> <Card>
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md"> <CardHeader>
<For data={TABS}> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{(tab) => ( <div className="relative w-full sm:max-w-sm">
<Button <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
key={tab} <Input
variant="text" className="pl-9"
className={cn( placeholder="Cari nama lengkap…"
'px-3 py-2 capitalize', value={search}
activeTab === tab && 'bg-white' onChange={(e) => setSearch(e.target.value)}
)} />
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 />
</div> </div>
</div> </div>
</div> </CardHeader>
<CardContent>
<Tabs
value={activeTab}
onValueChange={(v) => {
setActiveTab(v as 'mentor' | 'mentee');
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
<TabsList>
<TabsTrigger value="mentor" className="capitalize">
Mentor ({mentorTotal})
</TabsTrigger>
<TabsTrigger value="mentee" className="capitalize">
Mentee ({menteeTotal})
</TabsTrigger>
</TabsList>
<TabsContent value="mentor" className="mt-4">
{mentorLoading ? (
<div className="py-10 text-center text-sm text-muted-foreground">
Memuat data…
</div>
) : (
<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 ? ( <DeleteConfirmDialog
<div className="text-center py-8 text-neutral-400">Loading...</div> open={!!deletingId}
) : activeTab === 'mentor' ? ( onOpenChange={(o) => !o && setDeletingId(null)}
<DataTable data={mentors} columns={mentorColumns} table={mentorTable} /> onConfirm={() => deletingId && handleDelete(deletingId)}
) : ( title="Hapus akun mentor ini?"
<DataTable data={mentees} columns={menteeColumns} table={menteeTable} /> />
)}
</section>
</BackofficeWrapper> </BackofficeWrapper>
) );
} }
@@ -1,7 +1,7 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { FC, ReactElement, useState } from 'react' import { FC, ReactElement, useState } from 'react'
import { ControlledInputField, RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms' import { ControlledInputField, RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms'
import { Button, Select } from '@imphnen-frontend-service/ui/atoms' import { Button } from '@imphnen-frontend-service/ui/atoms'
import { ArrowLeftOutlined, ArrowRightOutlined } from '@ant-design/icons' import { ArrowLeftOutlined, ArrowRightOutlined } from '@ant-design/icons'
import { InputField, RegisterMentorStep, SelectField } from '@imphnen-frontend-service/ui/molecules' import { InputField, RegisterMentorStep, SelectField } from '@imphnen-frontend-service/ui/molecules'
@@ -1,4 +1,4 @@
import { Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms" import { Input, NativeSelect as Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils" import { cn } from "@imphnen-frontend-service/utils"
import { motion } from "framer-motion" import { motion } from "framer-motion"
@@ -1,5 +1,5 @@
import { FC, useState, useEffect, useCallback } from 'react'; import { FC, useState, useEffect, useCallback } from 'react';
import { Select } from '@imphnen-frontend-service/ui/atoms'; import { NativeSelect as Select } from '@imphnen-frontend-service/ui/atoms';
import { SectionWrapper } from '../shared/section-wrapper'; import { SectionWrapper } from '../shared/section-wrapper';
import { NotificationType } from '../modals/notification-modal'; import { NotificationType } from '../modals/notification-modal';
import { PersonalInfoSection } from '../sections/personal-info-section'; import { PersonalInfoSection } from '../sections/personal-info-section';
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "libs/ui/src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@imphnen-frontend-service/ui",
"utils": "@imphnen-frontend-service/utils",
"ui": "@imphnen-frontend-service/ui/atoms",
"lib": "@imphnen-frontend-service/utils",
"hooks": "@imphnen-frontend-service/utils"
},
"iconLibrary": "lucide"
}
@@ -0,0 +1,158 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@imphnen-frontend-service/utils';
import { buttonVariants } from '../button/button';
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
'fixed inset-0 z-50 bg-black/50',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg sm:max-w-lg',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn('text-lg font-semibold leading-none', className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants({ variant: 'primary' }), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'secondary' }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
+1
View File
@@ -0,0 +1 @@
export * from './alert-dialog';
+52
View File
@@ -0,0 +1,52 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@imphnen-frontend-service/utils';
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
'relative flex size-9 shrink-0 overflow-hidden rounded-full',
className
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn('aspect-square size-full object-cover', className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'flex size-full items-center justify-center rounded-full bg-primary-100 text-primary-700 text-sm font-medium',
className
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };
+1
View File
@@ -0,0 +1 @@
export * from './avatar';
+52
View File
@@ -0,0 +1,52 @@
'use client';
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@imphnen-frontend-service/utils';
const badgeVariants = cva(
'inline-flex items-center justify-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap transition-colors overflow-hidden [&>svg]:size-3 [&>svg]:pointer-events-none',
{
variants: {
variant: {
default:
'border-transparent bg-primary-500 text-white',
secondary:
'border-transparent bg-neutral-100 text-neutral-700',
success:
'border-transparent bg-success-100 text-success-700',
warning:
'border-transparent bg-warning-100 text-warning-800',
info:
'border-transparent bg-info-100 text-info-700',
destructive:
'border-transparent bg-danger-100 text-danger-700',
outline:
'border-neutral-200 bg-background text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span';
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
+1
View File
@@ -0,0 +1 @@
export * from './badge';
+107
View File
@@ -0,0 +1,107 @@
'use client';
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@imphnen-frontend-service/utils';
function Breadcrumb(props: React.ComponentProps<'nav'>) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
className
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-item"
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<'a'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'a';
return (
<Comp
data-slot="breadcrumb-link"
className={cn('transition-colors hover:text-foreground', className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn('font-normal text-foreground', className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
+1
View File
@@ -0,0 +1 @@
export * from './breadcrumb';
+34 -39
View File
@@ -1,31 +1,31 @@
'use client';
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority'; import { cva, type VariantProps } from 'class-variance-authority';
import {
FC,
ReactElement,
ButtonHTMLAttributes,
DetailedHTMLProps,
} from 'react';
import { cn } from '@imphnen-frontend-service/utils'; import { cn } from '@imphnen-frontend-service/utils';
export const buttonVariants = cva( export const buttonVariants = cva(
'inline-flex items-center justify-center font-[600] rounded-md px-[16px] py-[10px] transition-colors duration-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed', "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-semibold transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
primary: 'bg-primary-500 hover:bg-primary-600 text-white shadow-md', primary:
'bg-primary-500 text-white shadow-sm hover:bg-primary-600 active:bg-primary-700',
secondary: secondary:
'bg-white hover:text-primary-600 hover:bg-gray-50 text-primary-500 shadow-md border', 'border border-neutral-200 bg-white text-primary-500 shadow-sm hover:bg-primary-50 hover:text-primary-600 hover:border-primary-200',
text: 'bg-transparent hover:text-primary-600 hover:bg-gray-50 text-primary-500', text: 'bg-transparent text-primary-500 hover:bg-primary-50 hover:text-primary-600',
bordered: bordered:
'border border-primary-500 hover:border-primary-600 bg-transparent hover:text-primary-600 hover:bg-gray-50 text-primary-500', 'border border-primary-500 bg-transparent text-primary-500 hover:bg-primary-50 hover:border-primary-600 hover:text-primary-600',
success: 'bg-success-500 hover:bg-success-600 text-white shadow-md', success:
'bg-success-500 text-white shadow-sm hover:bg-success-600 active:bg-success-700',
danger: danger:
'bg-danger-100 hover:bg-danger-200 text-danger-500 shadow-md', 'bg-danger-100 text-danger-600 shadow-sm hover:bg-danger-200 hover:text-danger-700',
}, },
size: { size: {
sm: 'text-[12px] max-h-[36px]', sm: 'h-8 px-3 text-xs',
md: 'text-[15px] max-h-[40px]', md: 'h-9 px-4 text-sm',
lg: 'text-[19px] max-h-[44px]', lg: 'h-11 px-6 text-base',
icon: 'h-9 w-9 p-0', icon: 'h-9 w-9 p-0',
}, },
}, },
@@ -36,27 +36,22 @@ export const buttonVariants = cva(
} }
); );
type TButtonProps = DetailedHTMLProps< type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> & {
HTMLButtonElement asChild?: boolean;
> & };
VariantProps<typeof buttonVariants>;
export const Button: FC<TButtonProps> = ({ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
variant, ({ variant, size, asChild = false, className, ...props }, ref) => {
size, const Comp = asChild ? Slot : 'button';
disabled, return (
className, <Comp
children, ref={ref}
...rest data-slot="button"
}): ReactElement => { className={cn(buttonVariants({ variant, size }), className)}
return ( {...props}
<button />
className={cn(buttonVariants({ variant, size }), className)} );
disabled={disabled} }
{...rest} );
> Button.displayName = 'Button';
{children}
</button>
);
};
+39
View File
@@ -0,0 +1,39 @@
'use client';
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check, Minus } from 'lucide-react';
import { cn } from '@imphnen-frontend-service/utils';
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer size-4 shrink-0 rounded-sm border border-neutral-300 bg-background shadow-sm',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:bg-primary-500 data-[state=checked]:text-white data-[state=checked]:border-primary-500',
'data-[state=indeterminate]:bg-primary-500 data-[state=indeterminate]:text-white data-[state=indeterminate]:border-primary-500',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current"
>
{props.checked === 'indeterminate' ? (
<Minus className="size-3.5" />
) : (
<Check className="size-3.5" />
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
+1
View File
@@ -0,0 +1 @@
export * from './checkbox';
+2 -2
View File
@@ -2,7 +2,7 @@
import * as DialogPrimitive from '@radix-ui/react-dialog'; import * as DialogPrimitive from '@radix-ui/react-dialog';
import * as React from 'react'; import * as React from 'react';
import { LuX } from 'react-icons/lu'; import { X } from 'lucide-react';
import { cn } from '@imphnen-frontend-service/utils'; import { cn } from '@imphnen-frontend-service/utils';
function Dialog({ function Dialog({
@@ -70,7 +70,7 @@ function DialogContent({
data-slot="dialog-close" data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
> >
<LuX /> <X />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
@@ -0,0 +1,274 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@imphnen-frontend-service/utils';
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal
data-slot="dropdown-menu-portal"
{...props}
/>
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
'focus:bg-accent focus:text-accent-foreground',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'data-[inset]:pl-8',
"data-[variant=destructive]:text-danger-600 data-[variant=destructive]:focus:bg-danger-50 data-[variant=destructive]:focus:text-danger-700",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
'relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none',
'focus:bg-accent focus:text-accent-foreground',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
'relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none',
'focus:bg-accent focus:text-accent-foreground',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('-mx-1 my-1 h-px bg-neutral-100', className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
'flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none',
'focus:bg-accent focus:text-accent-foreground',
'data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
'data-[inset]:pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
+1
View File
@@ -0,0 +1 @@
export * from './dropdown-menu';
+16
View File
@@ -1,10 +1,26 @@
export * from './alert-dialog';
export * from './avatar';
export * from './badge';
export * from './breadcrumb';
export * from './button'; export * from './button';
export * from './card'; export * from './card';
export * from './checkbox';
export * from './dialog'; export * from './dialog';
export * from './drawer'; export * from './drawer';
export * from './dropdown-menu';
export * from './form'; export * from './form';
export * from './input'; export * from './input';
export * from './label'; export * from './label';
export * from './popover';
export * from './radio-group';
export * from './scroll-area';
export * from './select'; export * from './select';
export * from './separator';
export * from './sheet';
export * from './sidebar';
export * from './skeleton';
export * from './table';
export * from './tabs';
export * from './textarea'; export * from './textarea';
export * from './toggle'; export * from './toggle';
export * from './tooltip';
+63 -71
View File
@@ -1,15 +1,8 @@
'use client'; 'use client';
import { import * as React from 'react';
DetailedHTMLProps, import { Eye, EyeOff } from 'lucide-react';
FC,
InputHTMLAttributes,
ReactElement,
useState,
} from 'react';
import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons'; // Import Ant Design icons
import { cn } from '@imphnen-frontend-service/utils'; import { cn } from '@imphnen-frontend-service/utils';
import { Button } from '../button';
type TInputType = type TInputType =
| 'text' | 'text'
@@ -23,81 +16,80 @@ type TInputSize = 'sm' | 'md' | 'lg';
type Width = 'standard' | 'custom'; type Width = 'standard' | 'custom';
type TInputProps = Omit< type TInputProps = Omit<
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, React.InputHTMLAttributes<HTMLInputElement>,
'size' | 'type' 'size' | 'type'
> & { > & {
type?: TInputType; type?: TInputType;
size?: TInputSize; size?: TInputSize;
widthform?: Width; widthform?: Width;
disabled?: boolean;
}; };
const sizeClasses: Record<TInputSize, { textSize: string; iconSize: string }> = const sizeClasses: Record<TInputSize, string> = {
{ sm: 'h-8 text-xs',
sm: { textSize: 'text-[10px] h-[28px]', iconSize: 'text-[10px]' }, md: 'h-9 text-sm',
md: { textSize: 'text-[12px] h-[30px]', iconSize: 'text-[12px]' }, lg: 'h-11 text-base',
lg: { textSize: 'text-[15px] h-[34px]', iconSize: 'text-[15px]' }, };
};
const disabledClass = 'opacity-50 hover:border-neutral-200 cursor-not-allowed'; export const Input = React.forwardRef<HTMLInputElement, TInputProps>(
(
{
type = 'text',
size = 'md',
placeholder,
widthform = 'standard',
disabled,
className,
...rest
},
ref
) => {
const [showPassword, setShowPassword] = React.useState(false);
export const Input: FC<TInputProps> = ({ const togglePasswordVisibility = (e: React.MouseEvent) => {
type = 'text', e.preventDefault();
size = 'md', if (!disabled) setShowPassword((prev) => !prev);
placeholder = 'Placeholder', };
widthform = 'standard',
disabled,
className,
...rest
}): ReactElement => {
const [showPassword, setShowPassword] = useState(false); // State for password visibility
const togglePasswordVisibility = (e: React.FormEvent) => { return (
e.preventDefault(); <div className="relative flex items-center w-full">
if (!disabled) setShowPassword((prev) => !prev); <input
}; ref={ref}
data-slot="input"
const mergedClassName = cn( type={type === 'password' && showPassword ? 'text' : type}
`px-[12px] py-[8px] text-neutral-800 bg-white placeholder:text-neutral-300 border border-neutral-200 hover:border-blue-300 focus:outline-1 focus:outline-blue-500 rounded-md font-bai-jamjuree w-full ${ disabled={disabled}
widthform === 'standard' ? 'min-w-70' : '' placeholder={placeholder}
}`, className={cn(
sizeClasses[size].textSize, 'flex w-full rounded-md border border-input bg-background px-3 py-1 font-bai-jamjuree text-foreground shadow-xs transition-colors',
disabled && disabledClass, 'placeholder:text-muted-foreground',
className 'file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
); 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20',
return ( 'disabled:cursor-not-allowed disabled:opacity-50',
<div className="relative flex items-center"> sizeClasses[size],
<input type === 'password' && 'pr-9',
className={mergedClassName} widthform === 'standard' && 'min-w-0',
type={type === 'password' && showPassword ? 'text' : type} className
disabled={disabled} )}
placeholder={placeholder} {...rest}
{...rest} />
/> {type === 'password' && (
{type === 'password' && ( <button
<div className="absolute end-0 px-3 h-full flex items-center">
<Button
type="button" type="button"
variant="text" tabIndex={-1}
size={size}
onClick={togglePasswordVisibility} onClick={togglePasswordVisibility}
className={cn( disabled={disabled}
'relative aspect-square -me-2 p-1.5', className="absolute right-2 inline-flex h-7 w-7 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
sizeClasses[size].iconSize, aria-label={showPassword ? 'Hide password' : 'Show password'}
disabled && 'cursor-not-allowed'
)}
> >
{showPassword ? ( {showPassword ? (
<EyeInvisibleOutlined <EyeOff className="size-4" />
style={{ color: 'var(--color-neutral-500)' }}
/>
) : ( ) : (
<EyeOutlined style={{ color: 'var(--color-neutral-500)' }} /> <Eye className="size-4" />
)} )}
</Button> </button>
</div> )}
)} </div>
</div> );
); }
}; );
Input.displayName = 'Input';
+1
View File
@@ -0,0 +1 @@
export * from './popover';
+49
View File
@@ -0,0 +1,49 @@
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@imphnen-frontend-service/utils';
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
Loaded 50 of 82 files, more files were not shown because too many files have changed in this diff. Show more