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