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 {
|
'use client';
|
||||||
AppstoreOutlined,
|
|
||||||
AuditOutlined,
|
|
||||||
BookOutlined,
|
|
||||||
CalendarOutlined,
|
|
||||||
CommentOutlined,
|
|
||||||
DownOutlined,
|
|
||||||
InboxOutlined,
|
|
||||||
LogoutOutlined,
|
|
||||||
MessageOutlined,
|
|
||||||
ReadOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
RightOutlined,
|
|
||||||
ScheduleOutlined,
|
|
||||||
SettingOutlined,
|
|
||||||
StockOutlined,
|
|
||||||
UsergroupAddOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
UserSwitchOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
|
||||||
import { FC, ReactElement, useState } from 'react';
|
import { FC, ReactElement, useState } from 'react';
|
||||||
import { useLocation, useNavigate } from '@tanstack/react-router';
|
import { useLocation, useNavigate } from '@tanstack/react-router';
|
||||||
import { cn, For } from '@imphnen-frontend-service/utils';
|
import {
|
||||||
import { useSession } from '@imphnen-frontend-service/service';
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
UsersRound,
|
||||||
|
ClipboardCheck,
|
||||||
|
BookOpen,
|
||||||
|
MessageSquare,
|
||||||
|
MessageCircle,
|
||||||
|
Calendar,
|
||||||
|
CalendarClock,
|
||||||
|
Settings,
|
||||||
|
BarChart3,
|
||||||
|
RefreshCcw,
|
||||||
|
Inbox,
|
||||||
|
UserCog,
|
||||||
|
UserPlus,
|
||||||
|
ShieldCheck,
|
||||||
|
KeyRound,
|
||||||
|
User,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
type MenuItem = {
|
type MenuLink = {
|
||||||
label: string;
|
label: string;
|
||||||
href?: string;
|
href: string;
|
||||||
icon?: ReactElement;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
children?: Array<{ label: string; href: string; icon?: ReactElement }>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MenuGroup = {
|
||||||
|
label: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
children: MenuLink[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type MenuItem = MenuLink | MenuGroup;
|
||||||
|
|
||||||
const MENUS: MenuItem[] = [
|
const MENUS: MenuItem[] = [
|
||||||
{
|
{
|
||||||
label: 'Hackathon',
|
label: 'Hackathon',
|
||||||
icon: <StockOutlined className="text-p3" />,
|
icon: BarChart3,
|
||||||
children: [
|
children: [
|
||||||
{
|
{ label: 'Dashboard', href: '/hackathon-dashboard', icon: LayoutDashboard },
|
||||||
label: 'Dashboard',
|
{ label: 'Users', href: '/hackathon-users', icon: Users },
|
||||||
href: '/hackathon-dashboard',
|
{ label: 'Teams', href: '/hackathon-teams', icon: UsersRound },
|
||||||
icon: <AppstoreOutlined className="text-p3" />,
|
{ label: 'Submissions', href: '/hackathon-submissions', icon: ClipboardCheck },
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Users',
|
|
||||||
href: '/hackathon-users',
|
|
||||||
icon: <UserOutlined className="text-p3" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Teams',
|
|
||||||
href: '/hackathon-teams',
|
|
||||||
icon: <UsergroupAddOutlined className="text-p3" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Submissions',
|
|
||||||
href: '/hackathon-submissions',
|
|
||||||
icon: <AuditOutlined className="text-p3" />,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Dimentorin',
|
label: 'Dimentorin',
|
||||||
icon: <ReadOutlined className="text-p3" />,
|
icon: BookOpen,
|
||||||
children: [
|
children: [
|
||||||
{
|
{ label: 'Dashboard', href: '/dashboard-dimentorin', icon: LayoutDashboard },
|
||||||
label: 'Dashboard - Dimentorin',
|
{ label: 'Users', href: '/users-dimentorin', icon: UserCog },
|
||||||
href: '/dashboard-dimentorin',
|
{ label: 'Session', href: '/session-dimentorin', icon: CalendarClock },
|
||||||
icon: <AppstoreOutlined className="text-[20px]" />,
|
{ label: 'Content & Roadmap', href: '/roadmap-dimentorin', icon: BookOpen },
|
||||||
},
|
{ label: 'Feedback & Review', href: '/feedback-review-dimentorin', icon: MessageSquare },
|
||||||
{
|
{ label: 'Settings', href: '/settings-dimentorin', icon: Settings },
|
||||||
label: 'User - Dimentorin',
|
|
||||||
href: '/users-dimentorin',
|
|
||||||
icon: <UserSwitchOutlined className="text-[20px]" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Session - Dimentorin',
|
|
||||||
href: '/session-dimentorin',
|
|
||||||
icon: <ScheduleOutlined className="text-[20px]" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Content & Roadmap',
|
|
||||||
href: '/roadmap-dimentorin',
|
|
||||||
icon: <BookOutlined className="text-[20px]" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Feedback & Review',
|
|
||||||
href: '/feedback-review-dimentorin',
|
|
||||||
icon: <CommentOutlined className="text-[20px]" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Settings - Dimentorin',
|
|
||||||
href: '/settings-dimentorin',
|
|
||||||
icon: <SettingOutlined className="text-[20px]" />,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Gacha',
|
label: 'Gacha',
|
||||||
icon: <ReloadOutlined className="text-[20px]" />,
|
icon: RefreshCcw,
|
||||||
children: [
|
children: [
|
||||||
{
|
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||||
label: 'Dashboard & Set Gacha',
|
{ label: 'Gacha Roll', href: '/gacha-roll', icon: RefreshCcw },
|
||||||
href: '/dashboard',
|
{ label: 'Validasi Transaksi', href: '/transactions', icon: ClipboardCheck },
|
||||||
icon: <AppstoreOutlined className="text-[20px]" />,
|
{ label: 'Data Pengiriman', href: '/prizes', icon: Inbox },
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Gacha Roll',
|
|
||||||
href: '/gacha-roll',
|
|
||||||
icon: <ReloadOutlined className="text-[20px]" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Validasi Transaksi',
|
|
||||||
href: '/transactions',
|
|
||||||
icon: <AuditOutlined className="text-[20px]" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Data Pengiriman Hadiah',
|
|
||||||
href: '/prizes',
|
|
||||||
icon: <InboxOutlined className="text-[20px]" />,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'CMS',
|
label: 'CMS',
|
||||||
icon: <BookOutlined className="text-[20px]" />,
|
icon: BookOpen,
|
||||||
children: [
|
children: [
|
||||||
{
|
{ label: 'Events', href: '/cms-events', icon: Calendar },
|
||||||
label: 'Events',
|
{ label: 'Testimonials', href: '/cms-testimonials', icon: MessageCircle },
|
||||||
href: '/cms-events',
|
|
||||||
icon: <CalendarOutlined className="text-[20px]" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Testimonials',
|
|
||||||
href: '/cms-testimonials',
|
|
||||||
icon: <MessageOutlined className="text-[20px]" />,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Permissions',
|
|
||||||
href: '/permissions',
|
|
||||||
icon: <UserSwitchOutlined className="text-[20px]" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Roles',
|
|
||||||
href: '/roles',
|
|
||||||
icon: <UsergroupAddOutlined className="text-[20px]" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Data Akun',
|
|
||||||
href: '/accounts',
|
|
||||||
icon: <UserOutlined className="text-[20px]" />,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface SidebarProps {
|
const FLAT_MENUS: MenuLink[] = [
|
||||||
isOpen?: boolean;
|
{ label: 'Permissions', href: '/permissions', icon: ShieldCheck },
|
||||||
onClose?: () => void;
|
{ label: 'Roles', href: '/roles', icon: KeyRound },
|
||||||
}
|
{ label: 'Data Akun', href: '/accounts', icon: User },
|
||||||
|
];
|
||||||
|
|
||||||
export const BackofficeSidebar: FC<SidebarProps> = ({
|
const isMenuGroup = (item: MenuItem): item is MenuGroup =>
|
||||||
isOpen = false,
|
(item as MenuGroup).children !== undefined;
|
||||||
onClose,
|
|
||||||
}): ReactElement => {
|
export const BackofficeSidebar: FC = (): ReactElement => {
|
||||||
const { signOut } = useSession();
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const isActive = (path: string) => {
|
||||||
|
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return location.pathname.includes(path);
|
||||||
|
};
|
||||||
|
|
||||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>(() => {
|
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>(() => {
|
||||||
const initial: Record<string, boolean> = {};
|
const initial: Record<string, boolean> = {};
|
||||||
MENUS.forEach((menu) => {
|
MENUS.forEach((menu) => {
|
||||||
if (menu.children?.some((child) => child.href && location.pathname.includes(child.href))) {
|
if (
|
||||||
|
isMenuGroup(menu) &&
|
||||||
|
menu.children.some((child) => isActive(child.href))
|
||||||
|
) {
|
||||||
initial[menu.label] = true;
|
initial[menu.label] = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return initial;
|
return initial;
|
||||||
});
|
});
|
||||||
const isActive = (path: string) => {
|
|
||||||
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin')
|
|
||||||
return false;
|
|
||||||
return location.pathname.includes(path);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleGroup = (groupLabel: string) => {
|
const toggleGroup = (label: string) =>
|
||||||
setOpenGroups((prev) => ({ ...prev, [groupLabel]: !prev[groupLabel] }));
|
setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] }));
|
||||||
};
|
|
||||||
|
|
||||||
const sidebarContent = (
|
return (
|
||||||
<div className="w-[280px] bg-white h-svh py-10 lg:py-[60px] px-7 shadow-xl flex flex-col justify-between">
|
<Sidebar collapsible="offcanvas" variant="inset">
|
||||||
<div className="flex flex-col gap-10 lg:gap-20 justify-between items-center">
|
<SidebarHeader>
|
||||||
<div className="flex justify-around lg:justify-center items-center w-full">
|
<div className="flex items-center justify-center px-2 py-3">
|
||||||
<img
|
<img
|
||||||
src="/logos/simple.svg"
|
src="/logos/simple.svg"
|
||||||
alt="IMPHNEN Logo"
|
alt="IMPHNEN Logo"
|
||||||
className="w-[150px]"
|
className="h-10 w-auto"
|
||||||
/>
|
/>
|
||||||
{onClose && (
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
|
||||||
aria-label="Close sidebar"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 text-gray-500"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</SidebarHeader>
|
||||||
<nav className="flex flex-col gap-4 w-full h-[calc(100svh-20rem)] overflow-y-auto">
|
<SidebarContent>
|
||||||
<For data={MENUS}>
|
<SidebarGroup>
|
||||||
{(menu) =>
|
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
||||||
menu.children && menu.children.length > 0 ? (
|
<SidebarGroupContent>
|
||||||
<div key={menu.label} className="w-full">
|
<SidebarMenu>
|
||||||
<button
|
{MENUS.map((menu) => {
|
||||||
type="button"
|
const GroupIcon = menu.icon;
|
||||||
onClick={() => toggleGroup(menu.label)}
|
const open = !!openGroups[menu.label];
|
||||||
className={cn(
|
const groupHasActive =
|
||||||
'flex items-center justify-between w-full gap-3 px-2 py-2.5 rounded-md cursor-pointer',
|
isMenuGroup(menu) &&
|
||||||
openGroups[menu.label]
|
menu.children.some((c) => isActive(c.href));
|
||||||
? 'bg-primary-400 hover:bg-primary-500 text-white'
|
return (
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
<SidebarMenuItem key={menu.label}>
|
||||||
)}
|
<SidebarMenuButton
|
||||||
>
|
onClick={() => toggleGroup(menu.label)}
|
||||||
<div className="flex items-center gap-3">
|
isActive={groupHasActive && !open}
|
||||||
{menu.icon}
|
className="justify-between"
|
||||||
<span className="text-p3 font-medium">{menu.label}</span>
|
>
|
||||||
</div>
|
<span className="flex items-center gap-2">
|
||||||
<span className="text-label2">
|
<GroupIcon className="size-4" />
|
||||||
{openGroups[menu.label] ? (
|
<span>{menu.label}</span>
|
||||||
<DownOutlined className="text-label1" />
|
</span>
|
||||||
|
{open ? (
|
||||||
|
<ChevronDown className="size-3.5 opacity-60" />
|
||||||
) : (
|
) : (
|
||||||
<RightOutlined className="text-label1" />
|
<ChevronRight className="size-3.5 opacity-60" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</SidebarMenuButton>
|
||||||
</button>
|
{isMenuGroup(menu) && open && (
|
||||||
|
<SidebarMenuSub>
|
||||||
{openGroups[menu.label] && (
|
{menu.children.map((child) => {
|
||||||
<div className="mt-2 ml-6 flex flex-col gap-2">
|
const ChildIcon = child.icon;
|
||||||
{menu.children.map((child) => (
|
return (
|
||||||
<button
|
<SidebarMenuSubItem key={child.href}>
|
||||||
type="button"
|
<SidebarMenuSubButton
|
||||||
key={child.href}
|
asChild
|
||||||
onClick={() => { navigate({ to: child.href as string }); onClose?.(); }}
|
isActive={isActive(child.href)}
|
||||||
className={cn(
|
>
|
||||||
'flex items-center gap-3 px-2 py-2.5 rounded-md cursor-pointer text-left w-full',
|
<button
|
||||||
isActive(child.href)
|
type="button"
|
||||||
? 'bg-primary-100 text-primary-700 hover:bg-primary-200'
|
onClick={() =>
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
navigate({ to: child.href })
|
||||||
)}
|
}
|
||||||
>
|
className={cn(
|
||||||
{child.icon}
|
'w-full text-left',
|
||||||
<span className="text-label1 font-medium">
|
)}
|
||||||
{child.label}
|
>
|
||||||
</span>
|
<ChildIcon className="size-4" />
|
||||||
</button>
|
<span>{child.label}</span>
|
||||||
))}
|
</button>
|
||||||
</div>
|
</SidebarMenuSubButton>
|
||||||
)}
|
</SidebarMenuSubItem>
|
||||||
</div>
|
);
|
||||||
) : (
|
})}
|
||||||
<button
|
</SidebarMenuSub>
|
||||||
type="button"
|
)}
|
||||||
key={menu.href ?? menu.label}
|
</SidebarMenuItem>
|
||||||
onClick={() => { if (menu.href) { navigate({ to: menu.href as string }); onClose?.(); } }}
|
);
|
||||||
className={cn(
|
})}
|
||||||
'flex items-center justify-items-start gap-3 px-2 py-2.5 cursor-pointer text-left w-full',
|
</SidebarMenu>
|
||||||
menu.href && isActive(menu.href)
|
</SidebarGroupContent>
|
||||||
? 'bg-primary-500 text-white rounded-md'
|
</SidebarGroup>
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
<SidebarGroup>
|
||||||
)}
|
<SidebarGroupLabel>System</SidebarGroupLabel>
|
||||||
>
|
<SidebarGroupContent>
|
||||||
{menu.icon}
|
<SidebarMenu>
|
||||||
<span className="text-p3 font-medium">{menu.label}</span>
|
{FLAT_MENUS.map((menu) => {
|
||||||
</button>
|
const Icon = menu.icon;
|
||||||
)
|
return (
|
||||||
}
|
<SidebarMenuItem key={menu.href}>
|
||||||
</For>
|
<SidebarMenuButton
|
||||||
</nav>
|
isActive={isActive(menu.href)}
|
||||||
</div>
|
onClick={() => navigate({ to: menu.href })}
|
||||||
|
>
|
||||||
<div className="w-full">
|
<Icon className="size-4" />
|
||||||
<hr className="mb-5 border-primary-200" />
|
<span>{menu.label}</span>
|
||||||
<Button
|
</SidebarMenuButton>
|
||||||
onClick={() => { signOut(); navigate({ to: '/auth/login' as string }); }}
|
</SidebarMenuItem>
|
||||||
variant="text"
|
);
|
||||||
className="items-start justify-start gap-3 px-2 py-2.5 text-gray-700 hover:text-red-500 transition-colors w-full"
|
})}
|
||||||
>
|
</SidebarMenu>
|
||||||
<LogoutOutlined className="text-p3" />
|
</SidebarGroupContent>
|
||||||
<span className="text-p3 font-medium">Log Out</span>
|
</SidebarGroup>
|
||||||
</Button>
|
</SidebarContent>
|
||||||
</div>
|
</Sidebar>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="hidden lg:block sticky top-0 h-screen overflow-y-auto shadow">
|
|
||||||
{sidebarContent}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<div className="lg:hidden fixed inset-0 z-50">
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-black/50 transition-opacity"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
<div className="fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out">
|
|
||||||
{sidebarContent}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap');
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
@import 'tw-animate-css';
|
||||||
@source "../../../libs/ui/**/*.{ts,tsx}";
|
@source "../../../libs/ui/**/*.{ts,tsx}";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
@@ -96,6 +97,45 @@
|
|||||||
/* ~10px */
|
/* ~10px */
|
||||||
--text-label3: 0.677rem;
|
--text-label3: 0.677rem;
|
||||||
/* ~8px */
|
/* ~8px */
|
||||||
|
|
||||||
|
/* shadcn/ui semantic tokens — aliased to existing palette (keep colors consistent) */
|
||||||
|
--color-background: #ffffff;
|
||||||
|
--color-foreground: #3d3d3d;
|
||||||
|
--color-card: #ffffff;
|
||||||
|
--color-card-foreground: #3d3d3d;
|
||||||
|
--color-popover: #ffffff;
|
||||||
|
--color-popover-foreground: #3d3d3d;
|
||||||
|
--color-primary: #23a1eb;
|
||||||
|
--color-primary-foreground: #ffffff;
|
||||||
|
--color-secondary: #e7e7e7;
|
||||||
|
--color-secondary-foreground: #3d3d3d;
|
||||||
|
--color-muted: #f6f6f6;
|
||||||
|
--color-muted-foreground: #6d6d6d;
|
||||||
|
--color-accent: #e1f0fd;
|
||||||
|
--color-accent-foreground: #085f9c;
|
||||||
|
--color-destructive: #ff5242;
|
||||||
|
--color-destructive-foreground: #ffffff;
|
||||||
|
--color-border: #d1d1d1;
|
||||||
|
--color-input: #d1d1d1;
|
||||||
|
--color-ring: #3eb0f2;
|
||||||
|
|
||||||
|
--color-sidebar: #ffffff;
|
||||||
|
--color-sidebar-foreground: #4f4f4f;
|
||||||
|
--color-sidebar-primary: #23a1eb;
|
||||||
|
--color-sidebar-primary-foreground: #ffffff;
|
||||||
|
--color-sidebar-accent: #f0f8ff;
|
||||||
|
--color-sidebar-accent-foreground: #085f9c;
|
||||||
|
--color-sidebar-border: #e7e7e7;
|
||||||
|
--color-sidebar-ring: #3eb0f2;
|
||||||
|
|
||||||
|
--color-chart-1: #23a1eb;
|
||||||
|
--color-chart-2: #35ba43;
|
||||||
|
--color-chart-3: #04acf3;
|
||||||
|
--color-chart-4: #ffed27;
|
||||||
|
--color-chart-5: #ff5242;
|
||||||
|
|
||||||
|
--radius: 8px;
|
||||||
|
--font-sans: 'Bai Jamjuree', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@@ -1,63 +1,28 @@
|
|||||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
|
||||||
import { SessionToken } from '@imphnen-frontend-service/service'
|
import { SessionToken } from '@imphnen-frontend-service/service';
|
||||||
import { useState } from 'react'
|
import {
|
||||||
import { BackofficeSidebar } from '../components/sidebar'
|
SidebarInset,
|
||||||
|
SidebarProvider,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { BackofficeSidebar } from '../components/sidebar';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated')({
|
export const Route = createFileRoute('/_authenticated')({
|
||||||
beforeLoad: () => {
|
beforeLoad: () => {
|
||||||
const session = SessionToken.get()
|
const session = SessionToken.get();
|
||||||
if (!session?.token?.access_token) {
|
if (!session?.token?.access_token) {
|
||||||
throw redirect({ to: '/auth/login' })
|
throw redirect({ to: '/auth/login' });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
component: AuthenticatedLayout,
|
component: AuthenticatedLayout,
|
||||||
})
|
});
|
||||||
|
|
||||||
function AuthenticatedLayout() {
|
function AuthenticatedLayout() {
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
<SidebarProvider>
|
||||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
<BackofficeSidebar />
|
||||||
<BackofficeSidebar
|
<SidebarInset>
|
||||||
isOpen={mobileSidebarOpen}
|
<Outlet />
|
||||||
onClose={() => setMobileSidebarOpen(false)}
|
</SidebarInset>
|
||||||
/>
|
</SidebarProvider>
|
||||||
<div className="flex-1 overflow-auto">
|
);
|
||||||
<header
|
|
||||||
className={
|
|
||||||
'lg:hidden sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' +
|
|
||||||
(mobileSidebarOpen ? 'z-0' : 'z-30')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700 cursor-pointer"
|
|
||||||
onClick={() => setMobileSidebarOpen(true)}
|
|
||||||
aria-label="Open sidebar"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 6h16M4 12h16M4 18h16"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<h1 className="text-p3 font-semibold text-primary-700">
|
|
||||||
IMPHNEN Backoffice
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-5
@@ -1,5 +1,4 @@
|
|||||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"
|
||||||
import { PieLabelProps } from "recharts/types/polar/Pie"
|
|
||||||
|
|
||||||
type ChartProps = {
|
type ChartProps = {
|
||||||
name: string
|
name: string
|
||||||
@@ -14,14 +13,20 @@ const chartData: ChartProps[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const RADIAN = Math.PI / 180;
|
const RADIAN = Math.PI / 180;
|
||||||
const renderCustomizedLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent }: PieLabelProps) => {
|
const renderCustomizedLabel = (props: any) => {
|
||||||
|
const cx = props.cx ?? 0
|
||||||
|
const cy = props.cy ?? 0
|
||||||
|
const midAngle = props.midAngle ?? 0
|
||||||
|
const innerRadius = props.innerRadius ?? 0
|
||||||
|
const outerRadius = props.outerRadius ?? 0
|
||||||
|
const percent = props.percent ?? 0
|
||||||
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||||
const x = cx + radius * Math.cos(-(midAngle ?? 0) * RADIAN);
|
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||||
const y = cy + radius * Math.sin(-(midAngle ?? 0) * RADIAN);
|
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central">
|
<text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central">
|
||||||
{`${((percent ?? 1) * 100).toFixed(0)}%`}
|
{`${(percent * 100).toFixed(0)}%`}
|
||||||
</text>
|
</text>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { Button, Input, Select, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
|
import { Button, Input, NativeSelect as Select, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
|
||||||
import { cn } from "@imphnen-frontend-service/utils"
|
import { cn } from "@imphnen-frontend-service/utils"
|
||||||
import { FC } from "react"
|
import { FC } from "react"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { Button, Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
|
import { Button, Input, NativeSelect as Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
|
||||||
import { cn } from "@imphnen-frontend-service/utils"
|
import { cn } from "@imphnen-frontend-service/utils"
|
||||||
import { FC } from "react"
|
import { FC } from "react"
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import * as React from 'react'
|
import * as React from 'react';
|
||||||
import { Fragment, useState } from 'react'
|
import { Filter as FilterIcon, Search, Pencil } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
FilterOutlined,
|
Button,
|
||||||
SearchOutlined,
|
Card,
|
||||||
EditOutlined,
|
CardContent,
|
||||||
} from '@ant-design/icons'
|
CardHeader,
|
||||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
Checkbox,
|
||||||
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms'
|
Input,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
Badge,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
Filter,
|
||||||
|
BackofficeWrapper,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -15,107 +25,96 @@ import {
|
|||||||
PaginationState,
|
PaginationState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
useUserList,
|
useUserList,
|
||||||
TUsersListItem,
|
TUsersListItem,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/accounts')({
|
export const Route = createFileRoute('/_authenticated/accounts')({
|
||||||
component: AccountsPage,
|
component: AccountsPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function AccountsPage() {
|
function AccountsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = React.useState('');
|
||||||
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
const [showFilter, setShowFilter] = useState(false)
|
const [showFilter, setShowFilter] = React.useState(false);
|
||||||
|
|
||||||
const { data: usersData, isLoading } = useUserList({
|
const { data: usersData, isLoading } = useUserList({
|
||||||
search,
|
search,
|
||||||
page: pagination.pageIndex + 1,
|
page: pagination.pageIndex + 1,
|
||||||
per_page: pagination.pageSize,
|
per_page: pagination.pageSize,
|
||||||
})
|
});
|
||||||
|
|
||||||
const users: TUsersListItem[] = usersData?.data ?? []
|
const users: TUsersListItem[] = usersData?.data ?? [];
|
||||||
const totalItems = usersData?.meta?.total ?? users.length
|
const totalItems = usersData?.meta?.total ?? users.length;
|
||||||
|
|
||||||
const columns: ColumnDef<TUsersListItem>[] = [
|
const columns: ColumnDef<TUsersListItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
checked={
|
||||||
className="rounded"
|
table.getIsAllRowsSelected()
|
||||||
checked={table.getIsAllRowsSelected()}
|
? true
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
: table.getIsSomeRowsSelected()
|
||||||
|
? 'indeterminate'
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
|
||||||
|
}
|
||||||
|
aria-label="Select all"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={row.getIsSelected()}
|
checked={row.getIsSelected()}
|
||||||
onChange={row.getToggleSelectedHandler()}
|
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
||||||
|
aria-label="Select row"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{ header: 'No', accessorKey: 'id' },
|
||||||
header: 'No',
|
{ header: 'Nama Lengkap', accessorKey: 'fullname' },
|
||||||
accessorKey: 'id',
|
{ header: 'Email', accessorKey: 'email' },
|
||||||
},
|
{ header: 'Role', accessorKey: 'role' },
|
||||||
{
|
|
||||||
header: 'Nama Lengkap',
|
|
||||||
accessorKey: 'fullname',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Email',
|
|
||||||
accessorKey: 'email',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Role',
|
|
||||||
accessorKey: 'role',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
accessorKey: 'is_active',
|
accessorKey: 'is_active',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className={row.original.is_active ? 'text-success-500' : 'text-danger-500'}>
|
<Badge variant={row.original.is_active ? 'success' : 'destructive'}>
|
||||||
{row.original.is_active ? 'Aktif' : 'Tidak Aktif'}
|
{row.original.is_active ? 'Aktif' : 'Tidak Aktif'}
|
||||||
</span>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
navigate({ to: '/accounts/$id', params: { id: row.original.id } })
|
navigate({ to: '/accounts/$id', params: { id: row.original.id } });
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<EditOutlined /> Edit
|
<Pencil className="size-3.5" /> Edit
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: users,
|
data: users,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -123,52 +122,65 @@ function AccountsPage() {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<BackofficeWrapper
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
title="Data Akun"
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
description="Kelola akun pengguna yang terdaftar"
|
||||||
<h1 className="text-p2 font-semibold">Data Akun</h1>
|
>
|
||||||
</header>
|
<Card>
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
<CardHeader>
|
||||||
<div className="flex justify-between items-center gap-8 mb-2">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="relative w-full">
|
<div className="relative w-full sm:max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Cari berdasarkan nama lengkap, email"
|
placeholder="Cari nama lengkap atau email…"
|
||||||
className="pl-12 w-full max-h-full"
|
className="pl-9"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
|
||||||
<SearchOutlined />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
className="flex items-center gap-3"
|
|
||||||
disabled
|
|
||||||
onClick={() => setShowFilter(!showFilter)}
|
|
||||||
>
|
|
||||||
<FilterOutlined />
|
|
||||||
Filters
|
|
||||||
</Button>
|
|
||||||
{showFilter && (
|
|
||||||
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
|
|
||||||
<Filter onClose={() => setShowFilter(false)} options={[]} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<Popover open={showFilter} onOpenChange={setShowFilter}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="secondary" size="md">
|
||||||
|
<FilterIcon className="size-4" />
|
||||||
|
Filters
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-auto p-0">
|
||||||
|
<Filter
|
||||||
|
onClose={() => setShowFilter(false)}
|
||||||
|
options={[
|
||||||
|
{ id: 'all', value: 'all', label: 'Semua' },
|
||||||
|
{ id: 'active', value: 'active', label: 'Aktif' },
|
||||||
|
{ id: 'inactive', value: 'inactive', label: 'Tidak Aktif' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Memuat data…
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DataTable data={users} columns={columns} table={table} />
|
<DataTable
|
||||||
|
data={users}
|
||||||
|
columns={columns}
|
||||||
|
table={table}
|
||||||
|
manualPagination
|
||||||
|
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||||
|
currentPage={pagination.pageIndex + 1}
|
||||||
|
onPageChange={(p) =>
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardContent>
|
||||||
</main>
|
</Card>
|
||||||
</Fragment>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +1,122 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { useEffect, useState } from 'react'
|
import * as React from 'react';
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner';
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
import { ArrowLeft } from 'lucide-react';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
import {
|
||||||
import { InputField } from '@imphnen-frontend-service/ui/molecules'
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||||
import {
|
import {
|
||||||
useUserList,
|
useUserList,
|
||||||
useUpdateUserById,
|
useUpdateUserById,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/accounts_/$id')({
|
export const Route = createFileRoute('/_authenticated/accounts_/$id')({
|
||||||
component: AccountsEditPage,
|
component: AccountsEditPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function AccountsEditPage() {
|
function AccountsEditPage() {
|
||||||
const { id } = Route.useParams()
|
const { id } = Route.useParams();
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const updateUser = useUpdateUserById()
|
const updateUser = useUpdateUserById();
|
||||||
|
|
||||||
const { data: usersData, isLoading } = useUserList({ search: '', per_page: 100 })
|
const { data: usersData, isLoading } = useUserList({
|
||||||
const user = usersData?.data?.find((u) => u.id === id)
|
search: '',
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
const user = usersData?.data?.find((u) => u.id === id);
|
||||||
|
|
||||||
const [fullName, setFullName] = useState('')
|
const [fullName, setFullName] = React.useState('');
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = React.useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setFullName(user.fullname)
|
setFullName(user.fullname);
|
||||||
setEmail(user.email)
|
setEmail(user.email);
|
||||||
}
|
}
|
||||||
}, [user])
|
}, [user]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
await updateUser.mutateAsync({ id, data: { fullname: fullName, email } })
|
await updateUser.mutateAsync({
|
||||||
toast.success('Data akun berhasil diperbarui')
|
id,
|
||||||
navigate({ to: '/accounts' })
|
data: { fullname: fullName, email },
|
||||||
|
});
|
||||||
|
toast.success('Data akun berhasil diperbarui');
|
||||||
|
navigate({ to: '/accounts' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
toast.error('Data akun gagal diperbarui')
|
toast.error('Data akun gagal diperbarui');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<main className="w-full px-[48px] py-[40px]">
|
|
||||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
<BackofficeWrapper title="Edit Data Akun">
|
||||||
<div className="max-w-2xl mx-auto w-full">
|
<div className="mx-auto w-full max-w-2xl">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<Button
|
||||||
<button
|
variant="text"
|
||||||
onClick={() => navigate({ to: '/accounts' })}
|
size="sm"
|
||||||
className="text-primary-500 hover:text-primary-600"
|
onClick={() => navigate({ to: '/accounts' })}
|
||||||
>
|
className="mb-4 -ml-2"
|
||||||
<ArrowLeftOutlined className="text-[20px]" />
|
>
|
||||||
</button>
|
<ArrowLeft className="size-4" />
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Edit Data Akun</h1>
|
Kembali
|
||||||
</div>
|
</Button>
|
||||||
|
<Card>
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
<CardHeader>
|
||||||
<div className="flex flex-col gap-6">
|
<CardTitle>Edit Data Akun</CardTitle>
|
||||||
<InputField
|
</CardHeader>
|
||||||
label="Nama Lengkap"
|
<CardContent>
|
||||||
type="text"
|
{isLoading ? (
|
||||||
placeholder="Masukkan Nama Lengkap"
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
value={fullName}
|
Memuat data…
|
||||||
onChange={(e) => setFullName(e.target.value)}
|
</div>
|
||||||
size="lg"
|
) : (
|
||||||
className="w-full"
|
<div className="flex flex-col gap-4">
|
||||||
/>
|
<InputField
|
||||||
<InputField
|
label="Nama Lengkap"
|
||||||
label="Email"
|
type="text"
|
||||||
type="text"
|
placeholder="Masukkan nama lengkap"
|
||||||
placeholder="Masukkan Email"
|
value={fullName}
|
||||||
value={email}
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
size="md"
|
||||||
size="lg"
|
/>
|
||||||
className="w-full"
|
<InputField
|
||||||
/>
|
label="Email"
|
||||||
|
type="text"
|
||||||
<div className="flex gap-3 pt-4">
|
placeholder="Masukkan email"
|
||||||
<Button
|
value={email}
|
||||||
variant="primary"
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
size="lg"
|
size="md"
|
||||||
className="w-full"
|
/>
|
||||||
onClick={handleSubmit}
|
</div>
|
||||||
>
|
)}
|
||||||
Perbarui Data
|
</CardContent>
|
||||||
</Button>
|
<CardFooter className="justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="bordered"
|
variant="secondary"
|
||||||
size="lg"
|
size="md"
|
||||||
className="w-full"
|
onClick={() => navigate({ to: '/accounts' })}
|
||||||
onClick={() => navigate({ to: '/accounts' })}
|
>
|
||||||
>
|
Batal
|
||||||
Batal
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
</div>
|
variant="primary"
|
||||||
</div>
|
size="md"
|
||||||
</div>
|
onClick={handleSubmit}
|
||||||
|
disabled={updateUser.isPending || isLoading}
|
||||||
|
>
|
||||||
|
{updateUser.isPending ? 'Menyimpan…' : 'Perbarui Data'}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { Fragment, useState } from 'react'
|
import * as React from 'react';
|
||||||
|
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
AlertDialog,
|
||||||
EditOutlined,
|
AlertDialogAction,
|
||||||
DeleteOutlined,
|
AlertDialogCancel,
|
||||||
PlusOutlined,
|
AlertDialogContent,
|
||||||
} from '@ant-design/icons'
|
AlertDialogDescription,
|
||||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
AlertDialogFooter,
|
||||||
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Checkbox,
|
||||||
|
Input,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
BackofficeWrapper,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -15,76 +29,77 @@ import {
|
|||||||
PaginationState,
|
PaginationState,
|
||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
useEventList,
|
useEventList,
|
||||||
useDeleteEvent,
|
useDeleteEvent,
|
||||||
TEventsListItem,
|
TEventsListItem,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
import React from 'react'
|
import { toast } from 'sonner';
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/cms-events')({
|
export const Route = createFileRoute('/_authenticated/cms-events')({
|
||||||
component: CmsEventsPage,
|
component: CmsEventsPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function CmsEventsPage() {
|
function CmsEventsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = React.useState('');
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
|
|
||||||
const { data: eventsData, isLoading } = useEventList({
|
const { data: eventsData, isLoading } = useEventList({
|
||||||
search,
|
search,
|
||||||
page: pagination.pageIndex + 1,
|
page: pagination.pageIndex + 1,
|
||||||
per_page: pagination.pageSize,
|
per_page: pagination.pageSize,
|
||||||
})
|
});
|
||||||
const deleteEvent = useDeleteEvent()
|
const deleteEvent = useDeleteEvent();
|
||||||
|
|
||||||
const events: TEventsListItem[] = eventsData?.data ?? []
|
const events: TEventsListItem[] = eventsData?.data ?? [];
|
||||||
const totalItems = eventsData?.meta?.total ?? events.length
|
const totalItems = eventsData?.meta?.total ?? events.length;
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteEvent.mutateAsync(id)
|
await deleteEvent.mutateAsync(id);
|
||||||
toast.success('Data event berhasil dihapus')
|
toast.success('Data event berhasil dihapus');
|
||||||
setDeleteId(null)
|
setDeleteId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
toast.error('Data event gagal dihapus')
|
toast.error('Data event gagal dihapus');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<TEventsListItem>[] = [
|
const columns: ColumnDef<TEventsListItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
checked={
|
||||||
className="rounded"
|
table.getIsAllRowsSelected()
|
||||||
checked={table.getIsAllRowsSelected()}
|
? true
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
: table.getIsSomeRowsSelected()
|
||||||
|
? 'indeterminate'
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
|
||||||
|
}
|
||||||
|
aria-label="Select all"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={row.getIsSelected()}
|
checked={row.getIsSelected()}
|
||||||
onChange={row.getToggleSelectedHandler()}
|
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
||||||
|
aria-label="Select row"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{ header: 'Name', accessorKey: 'name' },
|
||||||
header: 'Name',
|
|
||||||
accessorKey: 'name',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
header: 'Location',
|
header: 'Location',
|
||||||
accessorKey: 'location',
|
accessorKey: 'location',
|
||||||
@@ -112,81 +127,49 @@ function CmsEventsPage() {
|
|||||||
header: 'Online',
|
header: 'Online',
|
||||||
accessorKey: 'is_online',
|
accessorKey: 'is_online',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span
|
<Badge variant={row.original.is_online ? 'success' : 'secondary'}>
|
||||||
className={`px-2 py-1 rounded-full text-label2 font-medium ${
|
|
||||||
row.original.is_online
|
|
||||||
? 'bg-green-100 text-green-700'
|
|
||||||
: 'bg-gray-100 text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{row.original.is_online ? 'Online' : 'Offline'}
|
{row.original.is_online ? 'Online' : 'Offline'}
|
||||||
</span>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex gap-[8px]">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
navigate({ to: '/cms-events/$id', params: { id: row.original.id } })
|
navigate({
|
||||||
|
to: '/cms-events/$id',
|
||||||
|
params: { id: row.original.id },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<EditOutlined /> Update
|
<Pencil className="size-3.5" />
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeleteId(row.original.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
{deleteId === row.original.id ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-label2 text-neutral-500">Yakin?</span>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleDelete(row.original.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Ya
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setDeleteId(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setDeleteId(row.original.id)
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<DeleteOutlined /> Delete
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: events,
|
data: events,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -194,53 +177,74 @@ function CmsEventsPage() {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<BackofficeWrapper title="CMS Events" description="Kelola event komunitas">
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
<Card>
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
<CardHeader>
|
||||||
<h1 className="text-p2 font-semibold">CMS Events</h1>
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
</header>
|
<div className="relative w-full sm:max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
|
||||||
<div className="flex justify-between items-center gap-8 mb-2">
|
|
||||||
<div className="relative w-full">
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Cari berdasarkan nama event"
|
placeholder="Cari nama event…"
|
||||||
className="pl-12 w-full max-h-full"
|
className="pl-9"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
|
||||||
<SearchOutlined />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
className="flex gap-3 text-nowrap"
|
|
||||||
onClick={() => navigate({ to: '/cms-events/create' })}
|
|
||||||
>
|
|
||||||
<PlusOutlined />
|
|
||||||
Tambah Event
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate({ to: '/cms-events/create' })}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Tambah Event
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Memuat data…
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DataTable
|
<DataTable
|
||||||
data={events}
|
data={events}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
pageSize={9}
|
|
||||||
table={table}
|
table={table}
|
||||||
|
manualPagination
|
||||||
|
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||||
|
currentPage={pagination.pageIndex + 1}
|
||||||
|
onPageChange={(p) =>
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardContent>
|
||||||
</main>
|
</Card>
|
||||||
</Fragment>
|
|
||||||
)
|
<AlertDialog
|
||||||
|
open={!!deleteId}
|
||||||
|
onOpenChange={(o) => !o && setDeleteId(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Hapus event ini?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Tindakan ini tidak dapat dibatalkan. Event akan dihapus permanen.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Batal</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => deleteId && handleDelete(deleteId)}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Hapus
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { Fragment, useState } from 'react'
|
import * as React from 'react';
|
||||||
|
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
AlertDialog,
|
||||||
EditOutlined,
|
AlertDialogAction,
|
||||||
DeleteOutlined,
|
AlertDialogCancel,
|
||||||
PlusOutlined,
|
AlertDialogContent,
|
||||||
} from '@ant-design/icons'
|
AlertDialogDescription,
|
||||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
AlertDialogFooter,
|
||||||
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Checkbox,
|
||||||
|
Input,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
BackofficeWrapper,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -15,86 +28,84 @@ import {
|
|||||||
PaginationState,
|
PaginationState,
|
||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
useTestimonialList,
|
useTestimonialList,
|
||||||
useDeleteTestimonial,
|
useDeleteTestimonial,
|
||||||
TTestimonialsListItem,
|
TTestimonialsListItem,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
import React from 'react'
|
import { toast } from 'sonner';
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/cms-testimonials')({
|
export const Route = createFileRoute('/_authenticated/cms-testimonials')({
|
||||||
component: CmsTestimonialsPage,
|
component: CmsTestimonialsPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function CmsTestimonialsPage() {
|
function CmsTestimonialsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = React.useState('');
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
|
|
||||||
const { data: testimonialsData, isLoading } = useTestimonialList({
|
const { data: testimonialsData, isLoading } = useTestimonialList({
|
||||||
search,
|
search,
|
||||||
page: pagination.pageIndex + 1,
|
page: pagination.pageIndex + 1,
|
||||||
per_page: pagination.pageSize,
|
per_page: pagination.pageSize,
|
||||||
})
|
});
|
||||||
const deleteTestimonial = useDeleteTestimonial()
|
const deleteTestimonial = useDeleteTestimonial();
|
||||||
|
|
||||||
const testimonials: TTestimonialsListItem[] = testimonialsData?.data ?? []
|
const testimonials: TTestimonialsListItem[] = testimonialsData?.data ?? [];
|
||||||
const totalItems = testimonialsData?.meta?.total ?? testimonials.length
|
const totalItems = testimonialsData?.meta?.total ?? testimonials.length;
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteTestimonial.mutateAsync(id)
|
await deleteTestimonial.mutateAsync(id);
|
||||||
toast.success('Data testimonial berhasil dihapus')
|
toast.success('Data testimonial berhasil dihapus');
|
||||||
setDeleteId(null)
|
setDeleteId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
toast.error('Data testimonial gagal dihapus')
|
toast.error('Data testimonial gagal dihapus');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<TTestimonialsListItem>[] = [
|
const columns: ColumnDef<TTestimonialsListItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
checked={
|
||||||
className="rounded"
|
table.getIsAllRowsSelected()
|
||||||
checked={table.getIsAllRowsSelected()}
|
? true
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
: table.getIsSomeRowsSelected()
|
||||||
|
? 'indeterminate'
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
table.toggleAllRowsSelected(!!v && v !== 'indeterminate')
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={row.getIsSelected()}
|
checked={row.getIsSelected()}
|
||||||
onChange={row.getToggleSelectedHandler()}
|
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{ header: 'User', accessorKey: 'user_fullname' },
|
||||||
header: 'User',
|
{ header: 'Role', accessorKey: 'role' },
|
||||||
accessorKey: 'user_fullname',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Role',
|
|
||||||
accessorKey: 'role',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
header: 'Content',
|
header: 'Content',
|
||||||
accessorKey: 'content',
|
accessorKey: 'content',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const content = row.original.content
|
const content = row.original.content;
|
||||||
return content.length > 80 ? `${content.substring(0, 80)}...` : content
|
return content.length > 80
|
||||||
|
? `${content.substring(0, 80)}…`
|
||||||
|
: content;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -110,67 +121,41 @@ function CmsTestimonialsPage() {
|
|||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex gap-[8px]">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
navigate({ to: '/cms-testimonials/$id', params: { id: row.original.id } })
|
navigate({
|
||||||
|
to: '/cms-testimonials/$id',
|
||||||
|
params: { id: row.original.id },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<EditOutlined /> Update
|
<Pencil className="size-3.5" />
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeleteId(row.original.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
{deleteId === row.original.id ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-label2 text-neutral-500">Yakin?</span>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleDelete(row.original.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Ya
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setDeleteId(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setDeleteId(row.original.id)
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<DeleteOutlined /> Delete
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: testimonials,
|
data: testimonials,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -178,53 +163,78 @@ function CmsTestimonialsPage() {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<BackofficeWrapper
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
title="CMS Testimonials"
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
description="Kelola testimonial pengguna"
|
||||||
<h1 className="text-p2 font-semibold">CMS Testimonials</h1>
|
>
|
||||||
</header>
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex justify-between items-center gap-8 mb-2">
|
<div className="relative w-full sm:max-w-sm">
|
||||||
<div className="relative w-full">
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Cari berdasarkan nama user"
|
placeholder="Cari nama user…"
|
||||||
className="pl-12 w-full max-h-full"
|
className="pl-9"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
|
||||||
<SearchOutlined />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
className="flex gap-3 text-nowrap"
|
|
||||||
onClick={() => navigate({ to: '/cms-testimonials/create' })}
|
|
||||||
>
|
|
||||||
<PlusOutlined />
|
|
||||||
Tambah Testimonial
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate({ to: '/cms-testimonials/create' })}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Tambah Testimonial
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Memuat data…
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DataTable
|
<DataTable
|
||||||
data={testimonials}
|
data={testimonials}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
pageSize={9}
|
|
||||||
table={table}
|
table={table}
|
||||||
|
manualPagination
|
||||||
|
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||||
|
currentPage={pagination.pageIndex + 1}
|
||||||
|
onPageChange={(p) =>
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardContent>
|
||||||
</main>
|
</Card>
|
||||||
</Fragment>
|
|
||||||
)
|
<AlertDialog
|
||||||
|
open={!!deleteId}
|
||||||
|
onOpenChange={(o) => !o && setDeleteId(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Hapus testimonial ini?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Tindakan ini tidak dapat dibatalkan. Testimonial akan dihapus
|
||||||
|
permanen.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Batal</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => deleteId && handleDelete(deleteId)}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Hapus
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,152 +1,212 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
import * as React from 'react';
|
||||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms'
|
import { Users, UserCog, CalendarClock, Activity, CircleCheck } from 'lucide-react';
|
||||||
import { For } from '@imphnen-frontend-service/utils'
|
import {
|
||||||
import { ReactElement } from 'react'
|
Card,
|
||||||
import { UserGrowthChart } from './_components/dashboard-dimentorin/chart/user-growth'
|
CardContent,
|
||||||
import { SessionStatusChart } from './_components/dashboard-dimentorin/chart/session-status'
|
CardDescription,
|
||||||
import { useMentorList, useUserList, useMySessions } from '@imphnen-frontend-service/service'
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { UserGrowthChart } from './_components/dashboard-dimentorin/chart/user-growth';
|
||||||
|
import { SessionStatusChart } from './_components/dashboard-dimentorin/chart/session-status';
|
||||||
|
import {
|
||||||
|
useMentorList,
|
||||||
|
useUserList,
|
||||||
|
useMySessions,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/dashboard-dimentorin')({
|
export const Route = createFileRoute('/_authenticated/dashboard-dimentorin')({
|
||||||
component: DashboardDimentorinPage,
|
component: DashboardDimentorinPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function DashboardDimentorinPage(): ReactElement {
|
type StatCardProps = {
|
||||||
const { data: mentorData } = useMentorList({ per_page: 5, sort_by: 'rating', order: 'desc' })
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
const { data: userData } = useUserList({ per_page: 1 })
|
label: string;
|
||||||
const { data: sessionsData } = useMySessions()
|
value: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
const totalMentors = mentorData?.meta?.total ?? 0
|
function StatCard({ icon: Icon, label, value }: StatCardProps) {
|
||||||
const totalUsers = userData?.meta?.total ?? 0
|
return (
|
||||||
const totalSessions = sessionsData?.total ?? 0
|
<Card>
|
||||||
const topMentors = mentorData?.data ?? []
|
<CardContent className="flex items-center gap-4 pt-6">
|
||||||
|
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary-100 text-primary-600">
|
||||||
|
<Icon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-2xl font-semibold leading-tight text-foreground">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const overviewStats = [
|
function DashboardDimentorinPage() {
|
||||||
{ label: 'Total Users', value: totalUsers },
|
const { data: mentorData } = useMentorList({
|
||||||
{ label: 'Total Mentors', value: totalMentors },
|
per_page: 5,
|
||||||
{ label: 'Total Sessions', value: totalSessions },
|
sort_by: 'rating',
|
||||||
{ label: 'Active Mentors', value: topMentors.filter((m) => m.status === 'active').length },
|
order: 'desc',
|
||||||
{ label: 'Completed Sessions', value: sessionsData?.sessions?.filter((s) => s.status === 'completed').length ?? 0 },
|
});
|
||||||
]
|
const { data: userData } = useUserList({ per_page: 1 });
|
||||||
|
const { data: sessionsData } = useMySessions();
|
||||||
|
|
||||||
|
const totalMentors = mentorData?.meta?.total ?? 0;
|
||||||
|
const totalUsers = userData?.meta?.total ?? 0;
|
||||||
|
const totalSessions = sessionsData?.total ?? 0;
|
||||||
|
const topMentors = mentorData?.data ?? [];
|
||||||
|
const activeMentors = topMentors.filter((m) => m.status === 'active').length;
|
||||||
|
const completedSessions =
|
||||||
|
sessionsData?.sessions?.filter((s) => s.status === 'completed').length ?? 0;
|
||||||
|
|
||||||
|
const topTopics = React.useMemo(() => {
|
||||||
|
const sessions = sessionsData?.sessions ?? [];
|
||||||
|
const topicCount: Record<string, number> = {};
|
||||||
|
sessions.forEach((s) => {
|
||||||
|
topicCount[s.topic] = (topicCount[s.topic] ?? 0) + 1;
|
||||||
|
});
|
||||||
|
return Object.entries(topicCount)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.slice(0, 5);
|
||||||
|
}, [sessionsData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="Dimentorin.dev">
|
<BackofficeWrapper
|
||||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-5">Overview</h1>
|
title="Dimentorin Overview"
|
||||||
|
description="Ringkasan metrik platform mentoring"
|
||||||
|
>
|
||||||
|
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<StatCard icon={Users} label="Total Users" value={totalUsers} />
|
||||||
|
<StatCard icon={UserCog} label="Total Mentors" value={totalMentors} />
|
||||||
|
<StatCard
|
||||||
|
icon={CalendarClock}
|
||||||
|
label="Total Sessions"
|
||||||
|
value={totalSessions}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Activity}
|
||||||
|
label="Active Mentors"
|
||||||
|
value={activeMentors}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={CircleCheck}
|
||||||
|
label="Completed Sessions"
|
||||||
|
value={completedSessions}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="space-y-14">
|
<section className="grid grid-cols-1 gap-4 lg:grid-cols-7">
|
||||||
<div>
|
<Card className="lg:col-span-5">
|
||||||
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
|
<CardHeader>
|
||||||
Overview
|
<CardTitle>User Growth</CardTitle>
|
||||||
</Button>
|
<CardDescription>Pertumbuhan user dari waktu ke waktu</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<UserGrowthChart />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Session Status</CardTitle>
|
||||||
|
<CardDescription>Distribusi status sesi</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<SessionStatusChart />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="grid grid-cols-5 gap-5">
|
<section className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<For data={overviewStats}>
|
<Card>
|
||||||
{(stat, index) => (
|
<CardHeader>
|
||||||
<div key={index} className="bg-white px-6 py-4 rounded-md shadow">
|
<CardTitle>Top 5 Mentors</CardTitle>
|
||||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">{stat.value}</h3>
|
<CardDescription>Mentor dengan rating tertinggi</CardDescription>
|
||||||
<p className="text-neutral-400 text-p3">{stat.label}</p>
|
</CardHeader>
|
||||||
</div>
|
<CardContent>
|
||||||
)}
|
<div className="rounded-md border border-neutral-200">
|
||||||
</For>
|
<Table>
|
||||||
</div>
|
<TableHeader>
|
||||||
</div>
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="w-[10%]">No.</TableHead>
|
||||||
<div>
|
<TableHead>Nama Lengkap</TableHead>
|
||||||
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
|
<TableHead>Avg Rating</TableHead>
|
||||||
Trends & Analytics
|
</TableRow>
|
||||||
</Button>
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
<div className="grid grid-cols-7 gap-x-5">
|
{topMentors.length === 0 ? (
|
||||||
<div className="bg-white px-6 py-4 rounded-lg col-span-5">
|
<TableRow className="hover:bg-transparent">
|
||||||
<div className="flex items-center justify-between mb-7">
|
<TableCell
|
||||||
<h2 className="font-semibold text-p3 text-neutral-700">User Growth</h2>
|
colSpan={3}
|
||||||
<div></div>
|
className="py-6 text-center text-sm text-muted-foreground"
|
||||||
</div>
|
>
|
||||||
<UserGrowthChart />
|
Belum ada data
|
||||||
</div>
|
</TableCell>
|
||||||
<div className="bg-white px-6 py-4 rounded-lg col-span-2">
|
</TableRow>
|
||||||
<h2 className="font-semibold text-p3 text-neutral-700 mb-7">Session Status</h2>
|
) : (
|
||||||
<SessionStatusChart />
|
topMentors.slice(0, 5).map((mentor, index) => (
|
||||||
</div>
|
<TableRow key={mentor.id}>
|
||||||
</div>
|
<TableCell>{index + 1}</TableCell>
|
||||||
</div>
|
<TableCell>{mentor.fullname ?? '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
<div className="grid grid-cols-2 gap-x-5">
|
{mentor.rating?.toFixed(1) ?? '-'}
|
||||||
<div className="bg-white px-7 py-4 rounded-lg">
|
</TableCell>
|
||||||
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top 5 Mentors</h2>
|
</TableRow>
|
||||||
|
|
||||||
<div>
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-label1 bg-primary-50 text-left rounded-full">
|
|
||||||
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
|
|
||||||
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
|
|
||||||
<th className="font-medium py-4 px-5 rounded-r-lg">Avg Rating</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
|
||||||
{topMentors.slice(0, 5).map((mentor, index) => (
|
|
||||||
<tr key={mentor.id} className="shadow rounded-lg">
|
|
||||||
<td className="py-4 px-5">{index + 1}</td>
|
|
||||||
<td className="py-4 px-5">{mentor.fullname ?? '-'}</td>
|
|
||||||
<td className="py-4 px-5">{mentor.rating?.toFixed(1) ?? '-'}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{topMentors.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={3} className="py-4 px-5 text-center text-neutral-400">Belum ada data</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white px-6 py-4 rounded-lg">
|
|
||||||
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top Booked Mentoring Topics</h2>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-label1 bg-primary-50 text-left font-medium">
|
|
||||||
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
|
|
||||||
<th className="font-medium py-4 px-5 w-3/5">Topik</th>
|
|
||||||
<th className="font-medium py-4 px-5 rounded-r-lg">Total Sesi</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
|
||||||
{(() => {
|
|
||||||
const sessions = sessionsData?.sessions ?? []
|
|
||||||
const topicCount: Record<string, number> = {}
|
|
||||||
sessions.forEach((s) => {
|
|
||||||
topicCount[s.topic] = (topicCount[s.topic] ?? 0) + 1
|
|
||||||
})
|
|
||||||
const topTopics = Object.entries(topicCount)
|
|
||||||
.sort(([, a], [, b]) => b - a)
|
|
||||||
.slice(0, 5)
|
|
||||||
if (topTopics.length === 0) {
|
|
||||||
return (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={3} className="py-4 px-5 text-center text-neutral-400">Belum ada data</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return topTopics.map(([topic, count], index) => (
|
|
||||||
<tr key={topic} className="shadow rounded-lg">
|
|
||||||
<td className="py-4 px-5">{index + 1}</td>
|
|
||||||
<td className="py-4 px-5">{topic}</td>
|
|
||||||
<td className="py-4 px-5">{count}</td>
|
|
||||||
</tr>
|
|
||||||
))
|
))
|
||||||
})()}
|
)}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top Booked Topics</CardTitle>
|
||||||
|
<CardDescription>Topik mentoring paling banyak dibooking</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border border-neutral-200">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="w-[10%]">No.</TableHead>
|
||||||
|
<TableHead>Topik</TableHead>
|
||||||
|
<TableHead>Total Sesi</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{topTopics.length === 0 ? (
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableCell
|
||||||
|
colSpan={3}
|
||||||
|
className="py-6 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
Belum ada data
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
topTopics.map(([topic, count], index) => (
|
||||||
|
<TableRow key={topic}>
|
||||||
|
<TableCell>{index + 1}</TableCell>
|
||||||
|
<TableCell>{topic}</TableCell>
|
||||||
|
<TableCell>{count}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
</BackofficeWrapper>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,195 +1,185 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import * as React from 'react';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
Plus,
|
||||||
ReloadOutlined,
|
UsersRound,
|
||||||
UsergroupAddOutlined,
|
UserMinus,
|
||||||
UsergroupDeleteOutlined,
|
UserCog,
|
||||||
UserSwitchOutlined,
|
RefreshCcw,
|
||||||
DeleteOutlined,
|
Pencil,
|
||||||
} from '@ant-design/icons'
|
Trash2,
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
MoreHorizontal,
|
||||||
import { Fragment, useState } from 'react'
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||||
import {
|
import {
|
||||||
useUserList,
|
useUserList,
|
||||||
useGachaItemList,
|
useGachaItemList,
|
||||||
useDeleteGachaItem,
|
useDeleteGachaItem,
|
||||||
TGachaItemDto,
|
TGachaItemDto,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner';
|
||||||
|
import { DeleteConfirmDialog } from '../../components/list-helpers';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/dashboard')({
|
export const Route = createFileRoute('/_authenticated/dashboard')({
|
||||||
component: DashboardPage,
|
component: DashboardPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
|
type StatCardProps = {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatCard({ icon: Icon, label, value }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-4 pt-6">
|
||||||
|
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary-100 text-primary-600">
|
||||||
|
<Icon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-2xl font-semibold leading-tight text-foreground">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DashboardPage() {
|
function DashboardPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const { data: usersData } = useUserList({ per_page: 1 })
|
const { data: usersData } = useUserList({ per_page: 1 });
|
||||||
const { data: gachaItemsData } = useGachaItemList({ per_page: 9 })
|
const { data: gachaItemsData } = useGachaItemList({ per_page: 9 });
|
||||||
const deleteItem = useDeleteGachaItem()
|
const deleteItem = useDeleteGachaItem();
|
||||||
|
|
||||||
const totalUsers = usersData?.meta?.total ?? 0
|
const totalUsers = usersData?.meta?.total ?? 0;
|
||||||
const gachaItems: TGachaItemDto[] = gachaItemsData?.data ?? []
|
const gachaItems: TGachaItemDto[] = gachaItemsData?.data ?? [];
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteItem.mutateAsync(id)
|
await deleteItem.mutateAsync(id);
|
||||||
toast.success('Item berhasil dihapus')
|
toast.success('Item berhasil dihapus');
|
||||||
setDeleteId(null)
|
setDeleteId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
toast.error('Item gagal dihapus')
|
toast.error('Item gagal dihapus');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<BackofficeWrapper
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
title="Gacha Dashboard"
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
description="Ringkasan statistik & daftar item gacha"
|
||||||
<h1 className="text-p2 font-semibold">Dashboard</h1>
|
>
|
||||||
</header>
|
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
icon={UsersRound}
|
||||||
|
label="Participants"
|
||||||
|
value={totalUsers.toLocaleString('id-ID')}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={RefreshCcw}
|
||||||
|
label="Gacha Items"
|
||||||
|
value={(gachaItemsData?.meta?.total ?? 0).toLocaleString('id-ID')}
|
||||||
|
/>
|
||||||
|
<StatCard icon={UserCog} label="Redeem" value="—" />
|
||||||
|
<StatCard icon={UserMinus} label="Inactive Users" value="—" />
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="flex justify-between gap-[40px] p-8 bg-white rounded-md">
|
<Card>
|
||||||
<div className="w-full flex flex-col gap-[40px]">
|
<CardHeader>
|
||||||
<section>
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h2 className="text-p2 font-medium text-primary-500 mb-8">
|
<div>
|
||||||
Summary
|
<CardTitle>Gacha Items</CardTitle>
|
||||||
</h2>
|
<CardDescription>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
Daftar item yang tersedia di gacha
|
||||||
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
</CardDescription>
|
||||||
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
</div>
|
||||||
<UsergroupAddOutlined className="text-[20px]" />
|
<Button
|
||||||
</div>
|
size="md"
|
||||||
<div className="flex flex-col gap-1">
|
onClick={() => navigate({ to: '/dashboard/create' })}
|
||||||
<h3 className="text-p1 font-semibold">{totalUsers}</h3>
|
>
|
||||||
<p className="text-label1 text-neutral-500">Participants</p>
|
<Plus className="size-4" />
|
||||||
</div>
|
Tambah Item
|
||||||
</div>
|
</Button>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
|
||||||
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
|
||||||
<ReloadOutlined className="text-[20px]" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h3 className="text-p1 font-semibold">{gachaItemsData?.meta?.total ?? 0}</h3>
|
|
||||||
<p className="text-label1 text-neutral-500">
|
|
||||||
Gacha Items
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
|
||||||
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
|
||||||
<UserSwitchOutlined className="text-[20px]" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h3 className="text-p1 font-semibold">-</h3>
|
|
||||||
<p className="text-label1 text-neutral-500">Redeem</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
|
||||||
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
|
||||||
<UsergroupDeleteOutlined className="text-[20px]" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h3 className="text-p1 font-semibold">-</h3>
|
|
||||||
<p className="text-label1 text-neutral-500">
|
|
||||||
Inactive Users
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="flex flex-col gap-8">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h2 className="text-p2 font-medium text-primary-500">
|
|
||||||
Gacha Items
|
|
||||||
</h2>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
className="items-end gap-3"
|
|
||||||
onClick={() => navigate({ to: '/dashboard/create' })}
|
|
||||||
>
|
|
||||||
<span>Tambah Item</span>
|
|
||||||
<PlusOutlined className="text-[16px]" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 max-h-140 overflow-auto">
|
|
||||||
{gachaItems.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="bg-white overflow-clip rounded-lg shadow-sm flex justify-between border border-neutral-100"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col py-4 px-6 gap-[8px]">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-p3 text-primary-500 font-medium">
|
|
||||||
{item.name}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center justify-start gap-10 text-label2 text-gray-500 mt-1">
|
|
||||||
<span>{item.id}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-start gap-2">
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="sm"
|
|
||||||
className="text-[10px] text-neutral-500 p-0 font-normal hover:bg-transparent hover:text-primary-500"
|
|
||||||
onClick={() => navigate({ to: '/dashboard/$id', params: { id: item.id } })}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
{deleteId === item.id ? (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-[10px] text-neutral-400">Yakin?</span>
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="sm"
|
|
||||||
className="text-[10px] text-red-500 p-0 font-normal hover:bg-transparent hover:text-red-700"
|
|
||||||
onClick={() => handleDelete(item.id)}
|
|
||||||
>
|
|
||||||
Ya
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="sm"
|
|
||||||
className="text-[10px] text-neutral-500 p-0 font-normal hover:bg-transparent"
|
|
||||||
onClick={() => setDeleteId(null)}
|
|
||||||
>
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="sm"
|
|
||||||
className="text-[10px] text-red-500 p-0 font-normal hover:bg-transparent hover:text-red-700"
|
|
||||||
onClick={() => setDeleteId(item.id)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<img src="gacha-clip.webp" alt={item.name} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{gachaItems.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Belum ada item gacha.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{gachaItems.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-start justify-between gap-3 rounded-md border border-neutral-200 p-4 transition-colors hover:border-primary-200 hover:bg-primary-50/40"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="truncate text-sm font-semibold text-primary-700">
|
||||||
|
{item.name}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-0.5 font-mono text-xs text-muted-foreground">
|
||||||
|
{item.id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="text" size="icon" aria-label="Actions">
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
navigate({
|
||||||
|
to: '/dashboard/$id',
|
||||||
|
params: { id: item.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
<span>Edit</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() => setDeleteId(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
<span>Hapus</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<img
|
<DeleteConfirmDialog
|
||||||
src="gacha.webp"
|
open={!!deleteId}
|
||||||
alt=""
|
onOpenChange={(o) => !o && setDeleteId(null)}
|
||||||
className="rounded-lg hidden xl:block xl:min-w-[436px] h-auto object-cover"
|
onConfirm={() => deleteId && handleDelete(deleteId)}
|
||||||
/>
|
title="Hapus item gacha ini?"
|
||||||
</div>
|
/>
|
||||||
</main>
|
</BackofficeWrapper>
|
||||||
</Fragment>
|
);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,91 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { SearchOutlined } from '@ant-design/icons'
|
import * as React from 'react';
|
||||||
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms'
|
import { Search, MessageSquare } from 'lucide-react';
|
||||||
import { BackofficeWrapper, DataTable } from '@imphnen-frontend-service/ui/organisms'
|
import {
|
||||||
import { cn, For } from '@imphnen-frontend-service/utils'
|
Badge,
|
||||||
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from '@tanstack/react-table'
|
Button,
|
||||||
import { ReactElement, useState } from 'react'
|
Card,
|
||||||
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service'
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
RowSelectionState,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service';
|
||||||
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
const TABS = {
|
export const Route = createFileRoute(
|
||||||
MENTORING: 'Mentoring',
|
'/_authenticated/feedback-review-dimentorin'
|
||||||
PLATFORM: 'Platform'
|
)({
|
||||||
} as const
|
|
||||||
type Tabs = typeof TABS[keyof typeof TABS]
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/feedback-review-dimentorin')({
|
|
||||||
component: FeedbackReviewDimentorinPage,
|
component: FeedbackReviewDimentorinPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function FeedbackReviewDimentorinPage(): ReactElement {
|
function FeedbackReviewDimentorinPage() {
|
||||||
const [activeTab, setActiveTab] = useState<Tabs>(TABS.MENTORING)
|
const [activeTab, setActiveTab] = React.useState<'mentoring' | 'platform'>(
|
||||||
|
'mentoring'
|
||||||
|
);
|
||||||
|
const [ratingFilter, setRatingFilter] = React.useState<string>('all');
|
||||||
|
const [statusFilter, setStatusFilter] = React.useState<string>('all');
|
||||||
|
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] =
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
React.useState<RowSelectionState>({});
|
||||||
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: sessionsData, isLoading } = useMySessions(
|
const { data: sessionsData, isLoading } = useMySessions(
|
||||||
activeTab === TABS.MENTORING ? { status: 'completed' } : undefined
|
activeTab === 'mentoring' ? { status: 'completed' } : undefined
|
||||||
)
|
);
|
||||||
|
|
||||||
const sessions: TSessionListItem[] = activeTab === TABS.MENTORING
|
const allSessions: TSessionListItem[] =
|
||||||
? (sessionsData?.sessions ?? [])
|
activeTab === 'mentoring' ? (sessionsData?.sessions ?? []) : [];
|
||||||
: []
|
|
||||||
const totalItems = activeTab === TABS.MENTORING
|
const sessions = React.useMemo(() => {
|
||||||
? (sessionsData?.total ?? sessions.length)
|
return allSessions.filter((s) => {
|
||||||
: 0
|
if (statusFilter !== 'all') {
|
||||||
|
const hasRating = !!s.rating;
|
||||||
|
if (statusFilter === 'done' && !hasRating) return false;
|
||||||
|
if (statusFilter === 'todo' && hasRating) return false;
|
||||||
|
}
|
||||||
|
if (ratingFilter !== 'all' && String(s.rating ?? '') !== ratingFilter)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [allSessions, statusFilter, ratingFilter]);
|
||||||
|
|
||||||
|
const totalItems =
|
||||||
|
activeTab === 'mentoring' ? (sessionsData?.total ?? sessions.length) : 0;
|
||||||
|
|
||||||
const columns: ColumnDef<TSessionListItem>[] = [
|
const columns: ColumnDef<TSessionListItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
meta: { cellClassName: cn('w-20') },
|
meta: { cellClassName: cn('w-10') },
|
||||||
header: ({ table }) => (
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
<input
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={table.getIsAllRowsSelected()}
|
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onChange={row.getToggleSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'name',
|
id: 'name',
|
||||||
@@ -81,39 +110,29 @@ function FeedbackReviewDimentorinPage(): ReactElement {
|
|||||||
header: 'Status',
|
header: 'Status',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const hasRating = !!row.original.rating
|
const hasRating = !!row.original.rating;
|
||||||
return (
|
return (
|
||||||
<div className={`py-2 px-4 rounded-md text-center ${hasRating ? 'bg-success-200 text-success-500' : 'bg-primary-200 text-primary-500'}`}>
|
<Badge variant={hasRating ? 'success' : 'info'}>
|
||||||
{hasRating ? 'Done' : 'To Do'}
|
{hasRating ? 'Done' : 'To Do'}
|
||||||
</div>
|
</Badge>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
meta: { cellClassName: cn('w-72') },
|
|
||||||
cell: () => (
|
cell: () => (
|
||||||
<Button
|
<Button variant="secondary" size="sm">
|
||||||
variant="primary"
|
<MessageSquare className="size-3.5" />
|
||||||
size="sm"
|
Lihat Feedback
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 w-max"
|
|
||||||
>
|
|
||||||
<SearchOutlined className="text-[16px]" /> Lihat Feedback
|
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: sessions,
|
data: sessions,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -121,64 +140,85 @@ function FeedbackReviewDimentorinPage(): ReactElement {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="Dimentorin.dev">
|
<BackofficeWrapper
|
||||||
<div className="mb-8 flex justify-between items-center">
|
title="Feedback & Review"
|
||||||
<h1 className="text-p1 font-semibold text-neutral-700">Feedback</h1>
|
description="Review feedback dari mentoring & platform"
|
||||||
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
>
|
||||||
<For data={Object.values(TABS)}>
|
<Card>
|
||||||
{(tab) => (
|
<CardContent className="pt-6">
|
||||||
<Button
|
<Tabs
|
||||||
key={tab}
|
value={activeTab}
|
||||||
variant="text"
|
onValueChange={(v) => {
|
||||||
className={cn('px-3 py-2 capitalize', activeTab === tab && 'bg-white')}
|
setActiveTab(v as 'mentoring' | 'platform');
|
||||||
onClick={() => {
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
setActiveTab(tab)
|
}}
|
||||||
setPagination((p) => ({ ...p, pageIndex: 0 }))
|
>
|
||||||
}}
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
>
|
<TabsList>
|
||||||
{tab}
|
<TabsTrigger value="mentoring">Mentoring</TabsTrigger>
|
||||||
</Button>
|
<TabsTrigger value="platform">Platform</TabsTrigger>
|
||||||
)}
|
</TabsList>
|
||||||
</For>
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
</div>
|
<div className="relative w-full sm:w-72">
|
||||||
</div>
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
className="pl-9"
|
||||||
<div className="flex justify-between items-center gap-5 mb-2">
|
placeholder="Cari nama mentor/mentee…"
|
||||||
<div className="relative w-full">
|
/>
|
||||||
<Input
|
</div>
|
||||||
placeholder="Cari berdasarkan nama mentor/mentee"
|
<Select value={ratingFilter} onValueChange={setRatingFilter}>
|
||||||
className="pl-12 w-full max-h-full"
|
<SelectTrigger className="w-28">
|
||||||
/>
|
<SelectValue placeholder="Rating" />
|
||||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
</SelectTrigger>
|
||||||
<SearchOutlined />
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Semua Rating</SelectItem>
|
||||||
|
<SelectItem value="4.5">4.5</SelectItem>
|
||||||
|
<SelectItem value="5">5</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-28">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Semua Status</SelectItem>
|
||||||
|
<SelectItem value="done">Done</SelectItem>
|
||||||
|
<SelectItem value="todo">To Do</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<Select>
|
|
||||||
<option disabled>Rating</option>
|
|
||||||
<option value="4.5">4.5</option>
|
|
||||||
<option value="5">5</option>
|
|
||||||
</Select>
|
|
||||||
<Select>
|
|
||||||
<option disabled>Status</option>
|
|
||||||
<option value="done">Done</option>
|
|
||||||
<option value="todo">To Do</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
<TabsContent value="mentoring" className="mt-4">
|
||||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
{isLoading ? (
|
||||||
) : activeTab === TABS.PLATFORM ? (
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
<div className="text-center py-8 text-neutral-400">
|
Memuat data…
|
||||||
Platform feedback tidak tersedia
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<DataTable
|
||||||
<DataTable data={sessions} columns={columns} table={table} />
|
data={sessions}
|
||||||
)}
|
columns={columns}
|
||||||
</section>
|
table={table}
|
||||||
|
manualPagination
|
||||||
|
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||||
|
currentPage={pagination.pageIndex + 1}
|
||||||
|
onPageChange={(p) =>
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="platform" className="mt-4">
|
||||||
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Platform feedback belum tersedia
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</BackofficeWrapper>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import * as React from 'react'
|
import * as React from 'react';
|
||||||
import { Fragment, useState } from 'react'
|
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
Button,
|
||||||
EditOutlined,
|
Card,
|
||||||
DeleteOutlined,
|
CardContent,
|
||||||
PlusOutlined,
|
CardHeader,
|
||||||
} from '@ant-design/icons'
|
Input,
|
||||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
|
import {
|
||||||
|
DataTable,
|
||||||
|
BackofficeWrapper,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -16,143 +19,101 @@ import {
|
|||||||
PaginationState,
|
PaginationState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
useGachaItemList,
|
useGachaItemList,
|
||||||
useDeleteGachaItem,
|
useDeleteGachaItem,
|
||||||
TGachaItemDto,
|
TGachaItemDto,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
DeleteConfirmDialog,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/gacha-roll')({
|
export const Route = createFileRoute('/_authenticated/gacha-roll')({
|
||||||
component: GachaRollPage,
|
component: GachaRollPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function GachaRollPage() {
|
function GachaRollPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = React.useState('');
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
|
|
||||||
const { data: itemsData, isLoading } = useGachaItemList({
|
const { data: itemsData, isLoading } = useGachaItemList({
|
||||||
search,
|
search,
|
||||||
page: pagination.pageIndex + 1,
|
page: pagination.pageIndex + 1,
|
||||||
per_page: pagination.pageSize,
|
per_page: pagination.pageSize,
|
||||||
})
|
});
|
||||||
const deleteItem = useDeleteGachaItem()
|
const deleteItem = useDeleteGachaItem();
|
||||||
|
|
||||||
const items: TGachaItemDto[] = itemsData?.data ?? []
|
const items: TGachaItemDto[] = itemsData?.data ?? [];
|
||||||
const totalItems = itemsData?.meta?.total ?? items.length
|
const totalItems = itemsData?.meta?.total ?? items.length;
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteItem.mutateAsync(id)
|
await deleteItem.mutateAsync(id);
|
||||||
toast.success('Item berhasil dihapus')
|
toast.success('Item berhasil dihapus');
|
||||||
setDeleteId(null)
|
setDeleteId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
toast.error('Item gagal dihapus')
|
toast.error('Item gagal dihapus');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<TGachaItemDto>[] = [
|
const columns: ColumnDef<TGachaItemDto>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
header: ({ table }) => (
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
<input
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={table.getIsAllRowsSelected()}
|
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onChange={row.getToggleSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'No',
|
|
||||||
accessorKey: 'id',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Nama Item',
|
|
||||||
accessorKey: 'name',
|
|
||||||
},
|
},
|
||||||
|
{ header: 'No', accessorKey: 'id' },
|
||||||
|
{ header: 'Nama Item', accessorKey: 'name' },
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex gap-[8px]">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
navigate({ to: '/gacha-roll/$id', params: { id: row.original.id } })
|
navigate({
|
||||||
|
to: '/gacha-roll/$id',
|
||||||
|
params: { id: row.original.id },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<EditOutlined /> Update
|
<Pencil className="size-3.5" />
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeleteId(row.original.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
{deleteId === row.original.id ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-label2 text-neutral-500">Yakin?</span>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleDelete(row.original.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Ya
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setDeleteId(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setDeleteId(row.original.id)
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<DeleteOutlined /> Delete
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: items,
|
data: items,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -160,48 +121,61 @@ function GachaRollPage() {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<BackofficeWrapper
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
title="Gacha Roll"
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
description="Kelola item hadiah gacha"
|
||||||
<h1 className="text-p2 font-semibold">Gacha Roll</h1>
|
>
|
||||||
</header>
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex justify-between items-center gap-8 mb-2">
|
<div className="relative w-full sm:max-w-sm">
|
||||||
<div className="relative w-full">
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Cari berdasarkan nama item"
|
placeholder="Cari nama item…"
|
||||||
className="pl-12 w-full max-h-full"
|
className="pl-9"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
|
||||||
<SearchOutlined />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
className="flex gap-3 text-nowrap"
|
|
||||||
onClick={() => navigate({ to: '/gacha-roll/create' })}
|
|
||||||
>
|
|
||||||
<PlusOutlined />
|
|
||||||
Tambah Item
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate({ to: '/gacha-roll/create' })}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Tambah Item
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Memuat data…
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DataTable data={items} columns={columns} table={table} />
|
<DataTable
|
||||||
|
data={items}
|
||||||
|
columns={columns}
|
||||||
|
table={table}
|
||||||
|
manualPagination
|
||||||
|
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||||
|
currentPage={pagination.pageIndex + 1}
|
||||||
|
onPageChange={(p) =>
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardContent>
|
||||||
</main>
|
</Card>
|
||||||
</Fragment>
|
|
||||||
)
|
<DeleteConfirmDialog
|
||||||
|
open={!!deleteId}
|
||||||
|
onOpenChange={(o) => !o && setDeleteId(null)}
|
||||||
|
onConfirm={() => deleteId && handleDelete(deleteId)}
|
||||||
|
title="Hapus item gacha ini?"
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +1,82 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms'
|
import * as React from 'react';
|
||||||
import { FC, ReactElement } from 'react'
|
import { UsersRound, UserCog, ClipboardCheck } from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||||
import {
|
import {
|
||||||
getAdminUsers,
|
getAdminUsers,
|
||||||
getAdminTeams,
|
getAdminTeams,
|
||||||
getAdminSubmissions,
|
getAdminSubmissions,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/hackathon-dashboard')({
|
export const Route = createFileRoute('/_authenticated/hackathon-dashboard')({
|
||||||
component: HackathonDashboardPage,
|
component: HackathonDashboardPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
|
type StatCardProps = {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatCard({ icon: Icon, label, value }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-4 pt-6">
|
||||||
|
<div className="grid size-11 shrink-0 place-items-center rounded-md bg-primary-100 text-primary-600">
|
||||||
|
<Icon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-2xl font-semibold leading-tight text-foreground">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HackathonDashboardPage() {
|
function HackathonDashboardPage() {
|
||||||
const { data: usersData } = useQuery({
|
const { data: usersData } = useQuery({
|
||||||
queryKey: ['admin-users-count'],
|
queryKey: ['admin-users-count'],
|
||||||
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
|
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: teamsData } = useQuery({
|
const { data: teamsData } = useQuery({
|
||||||
queryKey: ['admin-teams-count'],
|
queryKey: ['admin-teams-count'],
|
||||||
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
|
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: submissionsData } = useQuery({
|
const { data: submissionsData } = useQuery({
|
||||||
queryKey: ['admin-submissions-count'],
|
queryKey: ['admin-submissions-count'],
|
||||||
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
|
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
|
||||||
})
|
});
|
||||||
|
|
||||||
const totalParticipants = usersData?.meta?.total_data ?? '??'
|
const totalParticipants = usersData?.meta?.total_data ?? '—';
|
||||||
const totalTeams = teamsData?.meta?.total_data ?? '??'
|
const totalTeams = teamsData?.meta?.total_data ?? '—';
|
||||||
const totalSubmissions = submissionsData?.meta?.total_data ?? '??'
|
const totalSubmissions = submissionsData?.meta?.total_data ?? '—';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
<BackofficeWrapper
|
||||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
|
title="Hackathon Dashboard"
|
||||||
|
description="IMPHNEN x Kolosal.ai Hackathon 2025"
|
||||||
<section className="grid grid-cols-5 gap-5">
|
>
|
||||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
<StatCard
|
||||||
{totalParticipants}
|
icon={UserCog}
|
||||||
</h3>
|
label="Total Participants"
|
||||||
<p className="text-neutral-400 text-p3">Total Participants</p>
|
value={totalParticipants}
|
||||||
</div>
|
/>
|
||||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
<StatCard icon={UsersRound} label="Total Teams" value={totalTeams} />
|
||||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
<StatCard
|
||||||
{totalTeams}
|
icon={ClipboardCheck}
|
||||||
</h3>
|
label="Total Project Submitted"
|
||||||
<p className="text-neutral-400 text-p3">Total Teams</p>
|
value={totalSubmissions}
|
||||||
</div>
|
/>
|
||||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
|
||||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
|
||||||
{totalSubmissions}
|
|
||||||
</h3>
|
|
||||||
<p className="text-neutral-400 text-p3">Total Project Submitted</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</BackofficeWrapper>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,27 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import {
|
import * as React from 'react';
|
||||||
FC,
|
import { Search, Eye } from 'lucide-react';
|
||||||
ReactElement,
|
import SubmissionModal from './_components/hackathon-submissions/submission-modal';
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useCallback,
|
|
||||||
} from 'react'
|
|
||||||
import SubmissionModal from './_components/hackathon-submissions/submission-modal'
|
|
||||||
import {
|
import {
|
||||||
BackofficeWrapper,
|
BackofficeWrapper,
|
||||||
DataTable,
|
DataTable,
|
||||||
} from '@imphnen-frontend-service/ui/organisms'
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
|
||||||
import { cn } from '@imphnen-frontend-service/utils'
|
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
Badge,
|
||||||
FilterOutlined,
|
Button,
|
||||||
LoadingOutlined,
|
Card,
|
||||||
EyeOutlined,
|
CardContent,
|
||||||
} from '@ant-design/icons'
|
CardHeader,
|
||||||
import { useQuery } from '@tanstack/react-query'
|
Input,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
getAdminSubmissions,
|
getAdminSubmissions,
|
||||||
TAdminSubmissionItem,
|
TAdminSubmissionItem,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
type SubmissionType = TAdminSubmissionItem
|
type SubmissionType = TAdminSubmissionItem;
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/hackathon-submissions')({
|
export const Route = createFileRoute('/_authenticated/hackathon-submissions')({
|
||||||
component: HackathonSubmissionsPage,
|
component: HackathonSubmissionsPage,
|
||||||
@@ -38,20 +31,20 @@ export const Route = createFileRoute('/_authenticated/hackathon-submissions')({
|
|||||||
per_page: Number(search.per_page) || 10,
|
per_page: Number(search.per_page) || 10,
|
||||||
status: (search.status as string) || 'all',
|
status: (search.status as string) || 'all',
|
||||||
}),
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
function HackathonSubmissionsPage() {
|
function HackathonSubmissionsPage() {
|
||||||
const searchParams = Route.useSearch()
|
const searchParams = Route.useSearch();
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const currentPage = Math.max(1, searchParams.page)
|
const currentPage = Math.max(1, searchParams.page);
|
||||||
const searchQuery = searchParams.search || ''
|
const searchQuery = searchParams.search || '';
|
||||||
const perPage = searchParams.per_page || 10
|
const perPage = searchParams.per_page || 10;
|
||||||
const statusFilter = searchParams.status || 'all'
|
const statusFilter = searchParams.status || 'all';
|
||||||
|
|
||||||
const [showSubmissionModal, setShowSubmissionModal] = useState(false)
|
const [showSubmissionModal, setShowSubmissionModal] = React.useState(false);
|
||||||
const [selectedSubmission, setSelectedSubmission] =
|
const [selectedSubmission, setSelectedSubmission] =
|
||||||
useState<SubmissionType | null>(null)
|
React.useState<SubmissionType | null>(null);
|
||||||
const [globalFilter, setGlobalFilter] = useState(searchQuery)
|
const [globalFilter, setGlobalFilter] = React.useState(searchQuery);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: submissionsResponse,
|
data: submissionsResponse,
|
||||||
@@ -74,12 +67,12 @@ function HackathonSubmissionsPage() {
|
|||||||
}),
|
}),
|
||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
gcTime: 5 * 60 * 1000,
|
gcTime: 5 * 60 * 1000,
|
||||||
})
|
});
|
||||||
|
|
||||||
const totalData = submissionsResponse?.meta?.total_data || 0
|
const totalData = submissionsResponse?.meta?.total_data || 0;
|
||||||
const totalPages = submissionsResponse?.meta?.total_page || 1
|
const totalPages = submissionsResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
const handlePageChange = useCallback(
|
const handlePageChange = React.useCallback(
|
||||||
(newPage: number) => {
|
(newPage: number) => {
|
||||||
navigate({
|
navigate({
|
||||||
search: {
|
search: {
|
||||||
@@ -88,23 +81,23 @@ function HackathonSubmissionsPage() {
|
|||||||
search: searchQuery || undefined,
|
search: searchQuery || undefined,
|
||||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
});
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
},
|
},
|
||||||
[navigate, perPage, searchQuery, statusFilter]
|
[navigate, perPage, searchQuery, statusFilter]
|
||||||
)
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||||
navigate({ search: { page: totalPages } as any })
|
navigate({ search: { page: totalPages } as any });
|
||||||
}
|
}
|
||||||
}, [currentPage, totalPages, navigate, isLoading])
|
}, [currentPage, totalPages, navigate, isLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
setGlobalFilter(searchQuery)
|
setGlobalFilter(searchQuery);
|
||||||
}, [searchQuery])
|
}, [searchQuery]);
|
||||||
|
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = React.useCallback(() => {
|
||||||
navigate({
|
navigate({
|
||||||
search: {
|
search: {
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -112,56 +105,29 @@ function HackathonSubmissionsPage() {
|
|||||||
search: globalFilter.trim() || undefined,
|
search: globalFilter.trim() || undefined,
|
||||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
});
|
||||||
}, [globalFilter, navigate, perPage, statusFilter])
|
}, [globalFilter, navigate, perPage, statusFilter]);
|
||||||
|
|
||||||
const handleSearchKeyPress = useCallback(
|
const filteredData = React.useMemo<SubmissionType[]>(() => {
|
||||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
return (
|
||||||
if (e.key === 'Enter') {
|
((submissionsResponse?.data as any)?.data as SubmissionType[]) ??
|
||||||
handleSearch()
|
(submissionsResponse?.data as SubmissionType[]) ??
|
||||||
}
|
[]
|
||||||
},
|
);
|
||||||
[handleSearch]
|
}, [submissionsResponse]);
|
||||||
)
|
|
||||||
|
|
||||||
const handlePerPageChange = useCallback(
|
const statusVariants: Record<string, 'success' | 'warning' | 'secondary'> = {
|
||||||
(newPerPage: number) => {
|
submitted: 'success',
|
||||||
navigate({
|
pending: 'warning',
|
||||||
search: {
|
};
|
||||||
page: 1,
|
|
||||||
per_page: newPerPage,
|
|
||||||
search: searchQuery || undefined,
|
|
||||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
|
||||||
} as any,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[navigate, searchQuery, statusFilter]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleShowSubmissionModal = useCallback(
|
const columns: ColumnDef<SubmissionType>[] = React.useMemo(
|
||||||
(submission: SubmissionType) => {
|
|
||||||
setSelectedSubmission(submission)
|
|
||||||
setShowSubmissionModal(true)
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleCloseSubmissionModal = useCallback(() => {
|
|
||||||
setShowSubmissionModal(false)
|
|
||||||
setSelectedSubmission(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
|
||||||
return submissionsResponse?.data?.data || submissionsResponse?.data || []
|
|
||||||
}, [submissionsResponse])
|
|
||||||
|
|
||||||
const columns: ColumnDef<SubmissionType>[] = useMemo(
|
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'project_name',
|
accessorKey: 'project_name',
|
||||||
header: 'Project Name',
|
header: 'Project Name',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-medium text-neutral-900">
|
<span className="font-medium text-foreground">
|
||||||
{row.original.project_name}
|
{row.original.project_name}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
@@ -171,7 +137,7 @@ function HackathonSubmissionsPage() {
|
|||||||
accessorKey: 'team_id',
|
accessorKey: 'team_id',
|
||||||
header: 'Team ID',
|
header: 'Team ID',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm text-neutral-700 font-mono">
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
{row.original.team_id}
|
{row.original.team_id}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
@@ -180,30 +146,21 @@ function HackathonSubmissionsPage() {
|
|||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const status = row.original.status
|
<Badge
|
||||||
return (
|
variant={statusVariants[row.original.status] ?? 'secondary'}
|
||||||
<span
|
className="capitalize"
|
||||||
className={cn(
|
>
|
||||||
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
|
{row.original.status}
|
||||||
status === 'submitted'
|
</Badge>
|
||||||
? 'bg-success-100 text-success-800'
|
),
|
||||||
: status === 'pending'
|
|
||||||
? 'bg-orange-100 text-orange-800'
|
|
||||||
: 'bg-neutral-100 text-neutral-700'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'submitted_at',
|
accessorKey: 'submitted_at',
|
||||||
header: 'Submitted',
|
header: 'Submitted',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-neutral-900 text-sm">
|
<span className="text-sm text-foreground">
|
||||||
{new Date(row.original.submitted_at).toLocaleDateString('en-US', {
|
{new Date(row.original.submitted_at).toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -217,109 +174,87 @@ function HackathonSubmissionsPage() {
|
|||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
meta: { cellClassName: cn('w-48') },
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex items-center gap-2 text-sm px-4 py-2"
|
onClick={() => {
|
||||||
onClick={() => handleShowSubmissionModal(row.original)}
|
setSelectedSubmission(row.original);
|
||||||
|
setShowSubmissionModal(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<EyeOutlined className="text-sm" />
|
<Eye className="size-3.5" />
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[handleShowSubmissionModal]
|
[]
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
<BackofficeWrapper
|
||||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
title="Project Submissions"
|
||||||
Project Submissions
|
description="IMPHNEN x Kolosal.ai Hackathon 2025"
|
||||||
</h1>
|
>
|
||||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
<Card>
|
||||||
<div className="flex flex-wrap gap-3 items-center justify-between">
|
<CardHeader>
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="relative">
|
<div className="relative w-full sm:max-w-sm">
|
||||||
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<input
|
<Input
|
||||||
type="text"
|
className="pl-9"
|
||||||
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
placeholder="Cari nama project…"
|
||||||
placeholder="Search by project name..."
|
|
||||||
value={globalFilter}
|
value={globalFilter}
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
onKeyPress={handleSearchKeyPress}
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="relative">
|
</CardHeader>
|
||||||
<select
|
<CardContent>
|
||||||
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
{isLoading ? (
|
||||||
value={perPage}
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
onChange={(e) =>
|
Memuat submissions…
|
||||||
handlePerPageChange(parseInt(e.target.value, 10))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value={10}>10 / page</option>
|
|
||||||
<option value={20}>20 / page</option>
|
|
||||||
<option value={50}>50 / page</option>
|
|
||||||
<option value={100}>100 / page</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : filteredData.length > 0 ? (
|
||||||
{}
|
<>
|
||||||
{
|
<div className="mb-3 text-xs text-muted-foreground">
|
||||||
}
|
Menampilkan {filteredData.length} dari {totalData} submissions
|
||||||
</div>
|
(page {currentPage} / {totalPages})
|
||||||
</div>
|
{isFetching && (
|
||||||
|
<span className="ml-2 text-primary-500">Updating…</span>
|
||||||
{}
|
)}
|
||||||
{
|
</div>
|
||||||
}
|
<DataTable
|
||||||
|
data={filteredData}
|
||||||
{isLoading ? (
|
columns={columns}
|
||||||
<div className="flex items-center justify-center py-12">
|
pageSize={perPage}
|
||||||
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
manualPagination
|
||||||
<span className="ml-3 text-neutral-600">
|
pageCount={totalPages}
|
||||||
Loading submissions...
|
currentPage={currentPage}
|
||||||
</span>
|
onPageChange={handlePageChange}
|
||||||
</div>
|
/>
|
||||||
) : filteredData.length > 0 ? (
|
</>
|
||||||
<>
|
) : (
|
||||||
<div className="text-sm text-neutral-600">
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
Showing {filteredData.length} of {totalData} submissions (Page{' '}
|
Tidak ada submissions.
|
||||||
{currentPage} of {totalPages})
|
|
||||||
{isFetching && (
|
|
||||||
<span className="ml-2 text-primary-500">(Updating...)</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
)}
|
||||||
data={filteredData}
|
</CardContent>
|
||||||
columns={columns}
|
</Card>
|
||||||
pageSize={perPage}
|
|
||||||
manualPagination={true}
|
|
||||||
pageCount={totalPages}
|
|
||||||
currentPage={currentPage}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12 text-neutral-500">
|
|
||||||
No submissions found. Try adjusting your filters.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{selectedSubmission && (
|
{selectedSubmission && (
|
||||||
<SubmissionModal
|
<SubmissionModal
|
||||||
isOpen={showSubmissionModal}
|
isOpen={showSubmissionModal}
|
||||||
onClose={handleCloseSubmissionModal}
|
onClose={() => {
|
||||||
|
setShowSubmissionModal(false);
|
||||||
|
setSelectedSubmission(null);
|
||||||
|
}}
|
||||||
submission={selectedSubmission}
|
submission={selectedSubmission}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</BackofficeWrapper>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,30 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import {
|
import * as React from 'react';
|
||||||
FC,
|
import { Search, Plus, Users as TeamIcon, Pencil, X } from 'lucide-react';
|
||||||
ReactElement,
|
import ModalTeamDetail from './_components/hackathon-teams/modal-team-detail-new';
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useCallback,
|
|
||||||
} from 'react'
|
|
||||||
import ModalTeamDetail from './_components/hackathon-teams/modal-team-detail-new'
|
|
||||||
import { CityFilterSelect } from '../../components/city-filter-select'
|
|
||||||
import {
|
import {
|
||||||
BackofficeWrapper,
|
BackofficeWrapper,
|
||||||
DataTable,
|
DataTable,
|
||||||
} from '@imphnen-frontend-service/ui/organisms'
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
|
||||||
import { cn } from '@imphnen-frontend-service/utils'
|
|
||||||
import {
|
import {
|
||||||
EditOutlined,
|
Avatar,
|
||||||
TeamOutlined,
|
AvatarFallback,
|
||||||
SearchOutlined,
|
AvatarImage,
|
||||||
FilterOutlined,
|
Badge,
|
||||||
PlusOutlined,
|
Button,
|
||||||
LoadingOutlined,
|
Card,
|
||||||
} from '@ant-design/icons'
|
CardContent,
|
||||||
import { useQuery } from '@tanstack/react-query'
|
CardHeader,
|
||||||
|
Input,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
getAdminTeams,
|
getAdminTeams,
|
||||||
TAdminTeamItem,
|
TAdminTeamItem,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
type TeamType = TAdminTeamItem
|
type TeamType = TAdminTeamItem;
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/hackathon-teams')({
|
export const Route = createFileRoute('/_authenticated/hackathon-teams')({
|
||||||
component: HackathonTeamsPage,
|
component: HackathonTeamsPage,
|
||||||
@@ -40,22 +33,21 @@ export const Route = createFileRoute('/_authenticated/hackathon-teams')({
|
|||||||
search: (search.search as string) || '',
|
search: (search.search as string) || '',
|
||||||
per_page: Number(search.per_page) || 10,
|
per_page: Number(search.per_page) || 10,
|
||||||
}),
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
function HackathonTeamsPage() {
|
function HackathonTeamsPage() {
|
||||||
const searchParams = Route.useSearch()
|
const searchParams = Route.useSearch();
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const currentPage = Math.max(1, searchParams.page)
|
const currentPage = Math.max(1, searchParams.page);
|
||||||
const searchQuery = searchParams.search || ''
|
const searchQuery = searchParams.search || '';
|
||||||
const perPage = searchParams.per_page || 10
|
const perPage = searchParams.per_page || 10;
|
||||||
const [showDetailModal, setShowDetailModal] = useState(false)
|
|
||||||
const [showNewTeamModal, setShowNewTeamModal] = useState(false)
|
|
||||||
const [selectedTeam, setSelectedTeam] = useState<TeamType | null>(null)
|
|
||||||
useState<TeamType | null>(null)
|
|
||||||
const [globalFilter, setGlobalFilter] = useState(searchQuery)
|
|
||||||
|
|
||||||
const [visibilityFilter, setVisibilityFilter] = useState('all')
|
const [showDetailModal, setShowDetailModal] = React.useState(false);
|
||||||
const [cityFilter, setCityFilter] = useState('all')
|
const [showNewTeamModal, setShowNewTeamModal] = React.useState(false);
|
||||||
|
const [selectedTeam, setSelectedTeam] = React.useState<TeamType | null>(null);
|
||||||
|
const [globalFilter, setGlobalFilter] = React.useState(searchQuery);
|
||||||
|
const [visibilityFilter, setVisibilityFilter] = React.useState('all');
|
||||||
|
const [cityFilter, setCityFilter] = React.useState('all');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: teamsResponse,
|
data: teamsResponse,
|
||||||
@@ -78,12 +70,12 @@ function HackathonTeamsPage() {
|
|||||||
}),
|
}),
|
||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
gcTime: 5 * 60 * 1000,
|
gcTime: 5 * 60 * 1000,
|
||||||
})
|
});
|
||||||
|
|
||||||
const totalData = teamsResponse?.meta?.total_data || 0
|
const totalData = teamsResponse?.meta?.total_data || 0;
|
||||||
const totalPages = teamsResponse?.meta?.total_page || 1
|
const totalPages = teamsResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
const handlePageChange = useCallback(
|
const handlePageChange = React.useCallback(
|
||||||
(newPage: number) => {
|
(newPage: number) => {
|
||||||
navigate({
|
navigate({
|
||||||
search: {
|
search: {
|
||||||
@@ -91,142 +83,97 @@ function HackathonTeamsPage() {
|
|||||||
per_page: perPage !== 10 ? perPage : undefined,
|
per_page: perPage !== 10 ? perPage : undefined,
|
||||||
search: searchQuery || undefined,
|
search: searchQuery || undefined,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
});
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
},
|
},
|
||||||
[navigate, perPage, searchQuery]
|
[navigate, perPage, searchQuery]
|
||||||
)
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||||
navigate({ search: { page: totalPages } as any })
|
navigate({ search: { page: totalPages } as any });
|
||||||
}
|
}
|
||||||
}, [currentPage, totalPages, navigate, isLoading])
|
}, [currentPage, totalPages, navigate, isLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
setGlobalFilter(searchQuery)
|
setGlobalFilter(searchQuery);
|
||||||
}, [searchQuery])
|
}, [searchQuery]);
|
||||||
|
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = React.useCallback(() => {
|
||||||
navigate({
|
navigate({
|
||||||
search: {
|
search: {
|
||||||
page: 1,
|
page: 1,
|
||||||
per_page: perPage !== 10 ? perPage : undefined,
|
per_page: perPage !== 10 ? perPage : undefined,
|
||||||
search: globalFilter.trim() || undefined,
|
search: globalFilter.trim() || undefined,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
});
|
||||||
}, [globalFilter, navigate, perPage])
|
}, [globalFilter, navigate, perPage]);
|
||||||
|
|
||||||
const handleSearchKeyPress = useCallback(
|
const filteredData = React.useMemo<TeamType[]>(() => {
|
||||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
return (
|
||||||
if (e.key === 'Enter') {
|
((teamsResponse?.data as any)?.data as TeamType[]) ??
|
||||||
handleSearch()
|
(teamsResponse?.data as TeamType[]) ??
|
||||||
}
|
[]
|
||||||
},
|
);
|
||||||
[handleSearch]
|
}, [teamsResponse]);
|
||||||
)
|
|
||||||
|
|
||||||
const handlePerPageChange = useCallback(
|
const handleShowDetailModal = React.useCallback((team: TeamType) => {
|
||||||
(newPerPage: number) => {
|
setSelectedTeam(team);
|
||||||
navigate({
|
setShowDetailModal(true);
|
||||||
search: {
|
}, []);
|
||||||
page: 1,
|
|
||||||
per_page: newPerPage,
|
|
||||||
search: searchQuery || undefined,
|
|
||||||
} as any,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[navigate, searchQuery]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleShowDetailModal = useCallback((team: TeamType) => {
|
const columns: ColumnDef<TeamType>[] = React.useMemo(
|
||||||
setSelectedTeam(team)
|
|
||||||
setShowDetailModal(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleCloseDetailModal = useCallback(() => {
|
|
||||||
setShowDetailModal(false)
|
|
||||||
setSelectedTeam(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleShowNewTeamModal = useCallback(() => {
|
|
||||||
setShowNewTeamModal(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleCloseNewTeamModal = useCallback(() => {
|
|
||||||
setShowNewTeamModal(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
|
||||||
return teamsResponse?.data?.data || teamsResponse?.data || []
|
|
||||||
}, [teamsResponse])
|
|
||||||
|
|
||||||
const columns: ColumnDef<TeamType>[] = useMemo(
|
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: 'Team',
|
header: 'Team',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const team = row.original
|
<div className="flex items-center gap-3">
|
||||||
return (
|
<Avatar>
|
||||||
<div className="flex items-center gap-3">
|
<AvatarImage src={row.original.logo ?? undefined} alt={row.original.name} />
|
||||||
<div className="w-10 h-10 rounded-full bg-neutral-100 flex items-center justify-center shrink-0 overflow-hidden">
|
<AvatarFallback>
|
||||||
{team.logo ? (
|
<TeamIcon className="size-4 text-muted-foreground" />
|
||||||
<img
|
</AvatarFallback>
|
||||||
src={team.logo}
|
</Avatar>
|
||||||
alt={team.name}
|
<div className="min-w-0 flex-1">
|
||||||
className="w-full h-full object-cover"
|
<p
|
||||||
/>
|
className="truncate font-medium text-foreground"
|
||||||
) : (
|
title={row.original.name}
|
||||||
<TeamOutlined className="text-neutral-400 text-lg" />
|
>
|
||||||
)}
|
{row.original.name}
|
||||||
</div>
|
</p>
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p
|
|
||||||
className="font-medium text-neutral-900 truncate max-w-sm"
|
|
||||||
title={team.name}
|
|
||||||
>
|
|
||||||
{team.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
},
|
),
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'city',
|
accessorKey: 'city',
|
||||||
header: 'City',
|
header: 'City',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-neutral-700">{row.original.city}</span>
|
<span className="text-foreground">{row.original.city}</span>
|
||||||
),
|
),
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'visibility',
|
accessorKey: 'visibility',
|
||||||
header: 'Visibility',
|
header: 'Visibility',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const isPublic = row.original.visibility === 'public'
|
<Badge
|
||||||
return (
|
variant={
|
||||||
<span
|
row.original.visibility === 'public' ? 'success' : 'secondary'
|
||||||
className={cn(
|
}
|
||||||
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
|
>
|
||||||
isPublic
|
{row.original.visibility === 'public' ? 'Public' : 'Private'}
|
||||||
? 'bg-success-100 text-success-800'
|
</Badge>
|
||||||
: 'bg-neutral-100 text-neutral-700'
|
),
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isPublic ? 'Public' : 'Private'}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'leader',
|
id: 'leader',
|
||||||
header: 'Leader ID',
|
header: 'Leader ID',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-sm text-neutral-700 font-mono">
|
<div className="font-mono text-xs text-muted-foreground">
|
||||||
{row.original.leader_id}
|
{row.original.leader_id}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -236,7 +183,7 @@ function HackathonTeamsPage() {
|
|||||||
accessorKey: 'created_at',
|
accessorKey: 'created_at',
|
||||||
header: 'Created',
|
header: 'Created',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-neutral-900 text-sm">
|
<span className="text-sm text-foreground">
|
||||||
{new Date(row.original.created_at).toLocaleDateString('en-UK', {
|
{new Date(row.original.created_at).toLocaleDateString('en-UK', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -250,168 +197,127 @@ function HackathonTeamsPage() {
|
|||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
meta: { cellClassName: cn('w-48') },
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-2">
|
<Button
|
||||||
<Button
|
variant="secondary"
|
||||||
variant="primary"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => handleShowDetailModal(row.original)}
|
||||||
className="flex items-center gap-2 text-sm px-4 py-2"
|
>
|
||||||
onClick={() => handleShowDetailModal(row.original)}
|
<Pencil className="size-3.5" />
|
||||||
>
|
Manage
|
||||||
<EditOutlined className="text-sm" />
|
</Button>
|
||||||
Manage
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[handleShowDetailModal]
|
[handleShowDetailModal]
|
||||||
)
|
);
|
||||||
|
|
||||||
|
const hasActiveFilters = visibilityFilter !== 'all' || cityFilter !== 'all';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
<BackofficeWrapper
|
||||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
title="Hackathon Teams"
|
||||||
Team Management
|
description="IMPHNEN x Kolosal.ai Hackathon 2025"
|
||||||
</h1>
|
>
|
||||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
<Card>
|
||||||
<div className="flex flex-wrap gap-3 items-center justify-between">
|
<CardHeader>
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="relative">
|
<div className="relative w-full sm:max-w-sm">
|
||||||
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<input
|
<Input
|
||||||
type="text"
|
className="pl-9"
|
||||||
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
placeholder="Cari nama atau kota…"
|
||||||
placeholder="Search teams by name or city..."
|
|
||||||
value={globalFilter}
|
value={globalFilter}
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
onKeyPress={handleSearchKeyPress}
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Button onClick={() => setShowNewTeamModal(true)} size="md">
|
||||||
<div className="relative">
|
<Plus className="size-4" />
|
||||||
<select
|
|
||||||
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
|
||||||
value={perPage}
|
|
||||||
onChange={(e) =>
|
|
||||||
handlePerPageChange(parseInt(e.target.value, 10))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value={10}>10 / page</option>
|
|
||||||
<option value={20}>20 / page</option>
|
|
||||||
<option value={50}>50 / page</option>
|
|
||||||
<option value={100}>100 / page</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{}
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
{}
|
|
||||||
{
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
className="flex items-center gap-2 px-4 py-2"
|
|
||||||
onClick={handleShowNewTeamModal}
|
|
||||||
>
|
|
||||||
<PlusOutlined className="text-sm" />
|
|
||||||
Add Team
|
Add Team
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
{(visibilityFilter !== 'all' || cityFilter !== 'all') && (
|
{hasActiveFilters && (
|
||||||
<div className="flex flex-wrap gap-2 items-center">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm text-neutral-600">Active filters:</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Active filters:
|
||||||
{visibilityFilter !== 'all' && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
|
||||||
Visibility: {visibilityFilter}
|
|
||||||
<button
|
|
||||||
onClick={() => setVisibilityFilter('all')}
|
|
||||||
className="text-info-600 hover:text-info-800 cursor-pointer"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
{visibilityFilter !== 'all' && (
|
||||||
|
<Badge variant="info" className="gap-1">
|
||||||
{cityFilter !== 'all' && (
|
Visibility: {visibilityFilter}
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
<button onClick={() => setVisibilityFilter('all')}>
|
||||||
City: {cityFilter}
|
<X className="size-3" />
|
||||||
<button
|
</button>
|
||||||
onClick={() => setCityFilter('all')}
|
</Badge>
|
||||||
className="text-green-600 hover:text-green-800 cursor-pointer"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setVisibilityFilter('all')
|
|
||||||
setCityFilter('all')
|
|
||||||
setGlobalFilter('')
|
|
||||||
}}
|
|
||||||
className="text-sm text-neutral-600"
|
|
||||||
>
|
|
||||||
Clear All
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
|
||||||
<span className="ml-3 text-neutral-600">Loading teams...</span>
|
|
||||||
</div>
|
|
||||||
) : filteredData.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="text-sm text-neutral-600">
|
|
||||||
Showing {filteredData.length} of {totalData} teams (Page{' '}
|
|
||||||
{currentPage} of {totalPages})
|
|
||||||
{isFetching && (
|
|
||||||
<span className="ml-2 text-primary-500">(Updating...)</span>
|
|
||||||
)}
|
)}
|
||||||
|
{cityFilter !== 'all' && (
|
||||||
|
<Badge variant="success" className="gap-1">
|
||||||
|
City: {cityFilter}
|
||||||
|
<button onClick={() => setCityFilter('all')}>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setVisibilityFilter('all');
|
||||||
|
setCityFilter('all');
|
||||||
|
setGlobalFilter('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
)}
|
||||||
data={filteredData}
|
{isLoading ? (
|
||||||
columns={columns}
|
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||||
pageSize={perPage}
|
Memuat data teams…
|
||||||
manualPagination={true}
|
</div>
|
||||||
pageCount={totalPages}
|
) : filteredData.length > 0 ? (
|
||||||
currentPage={currentPage}
|
<>
|
||||||
onPageChange={handlePageChange}
|
<div className="text-xs text-muted-foreground">
|
||||||
/>
|
Menampilkan {filteredData.length} dari {totalData} teams (page{' '}
|
||||||
</>
|
{currentPage} / {totalPages})
|
||||||
) : (
|
{isFetching && (
|
||||||
<div className="text-center py-12 text-neutral-500">
|
<span className="ml-2 text-primary-500">Updating…</span>
|
||||||
No teams found. Try adjusting your filters.
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<DataTable
|
||||||
</section>
|
data={filteredData}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={perPage}
|
||||||
|
manualPagination
|
||||||
|
pageCount={totalPages}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
|
Tidak ada team. Coba ubah filter pencarian.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<ModalTeamDetail
|
<ModalTeamDetail
|
||||||
isOpen={showDetailModal}
|
isOpen={showDetailModal}
|
||||||
onClose={handleCloseDetailModal}
|
onClose={() => {
|
||||||
|
setShowDetailModal(false);
|
||||||
|
setSelectedTeam(null);
|
||||||
|
}}
|
||||||
team={selectedTeam}
|
team={selectedTeam}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModalTeamDetail
|
<ModalTeamDetail
|
||||||
isOpen={showNewTeamModal}
|
isOpen={showNewTeamModal}
|
||||||
onClose={handleCloseNewTeamModal}
|
onClose={() => setShowNewTeamModal(false)}
|
||||||
team={null}
|
team={null}
|
||||||
/>
|
/>
|
||||||
</BackofficeWrapper>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,31 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import {
|
import * as React from 'react';
|
||||||
FC,
|
import { Search, Plus, User, Pencil, X } from 'lucide-react';
|
||||||
ReactElement,
|
import ModalUserDetail from './_components/hackathon-users/modal-user-detail';
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useCallback,
|
|
||||||
} from 'react'
|
|
||||||
import ModalUserDetail from './_components/hackathon-users/modal-user-detail'
|
|
||||||
import {
|
import {
|
||||||
BackofficeWrapper,
|
BackofficeWrapper,
|
||||||
DataTable,
|
DataTable,
|
||||||
} from '@imphnen-frontend-service/ui/organisms'
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import { ColumnDef } from '@tanstack/react-table'
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
|
||||||
import { cn } from '@imphnen-frontend-service/utils'
|
|
||||||
import {
|
import {
|
||||||
EditOutlined,
|
Avatar,
|
||||||
UserOutlined,
|
AvatarFallback,
|
||||||
SearchOutlined,
|
AvatarImage,
|
||||||
FilterOutlined,
|
Badge,
|
||||||
PlusOutlined,
|
Button,
|
||||||
LoadingOutlined,
|
Card,
|
||||||
} from '@ant-design/icons'
|
CardContent,
|
||||||
import { CityFilterSelect } from '../../components/city-filter-select'
|
CardHeader,
|
||||||
import { useQuery } from '@tanstack/react-query'
|
Input,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
getAdminUsers,
|
getAdminUsers,
|
||||||
TAdminUserItem,
|
TAdminUserItem,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
type UserType = TAdminUserItem
|
type UserType = TAdminUserItem;
|
||||||
|
|
||||||
const skillsOptions = [
|
|
||||||
'Frontend Developer',
|
|
||||||
'Backend Developer',
|
|
||||||
'Full Stack Developer',
|
|
||||||
'DevOps Engineer',
|
|
||||||
'UI/UX Designer',
|
|
||||||
'Product Manager',
|
|
||||||
'Data Scientist',
|
|
||||||
'Mobile Developer',
|
|
||||||
]
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/hackathon-users')({
|
export const Route = createFileRoute('/_authenticated/hackathon-users')({
|
||||||
component: HackathonUsersPage,
|
component: HackathonUsersPage,
|
||||||
@@ -51,36 +34,28 @@ export const Route = createFileRoute('/_authenticated/hackathon-users')({
|
|||||||
search: (search.search as string) || '',
|
search: (search.search as string) || '',
|
||||||
per_page: Number(search.per_page) || 10,
|
per_page: Number(search.per_page) || 10,
|
||||||
}),
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
function HackathonUsersPage() {
|
function HackathonUsersPage() {
|
||||||
const searchParams = Route.useSearch()
|
const searchParams = Route.useSearch();
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const currentPage = Math.max(1, searchParams.page)
|
const currentPage = Math.max(1, searchParams.page);
|
||||||
const searchQuery = searchParams.search || ''
|
const searchQuery = searchParams.search || '';
|
||||||
const perPage = searchParams.per_page || 10
|
const perPage = searchParams.per_page || 10;
|
||||||
const [showDetailModal, setShowDetailModal] = useState(false)
|
|
||||||
const [showNewUserModal, setShowNewUserModal] = useState(false)
|
|
||||||
const [selectedUser, setSelectedUser] = useState<UserType | null>(null)
|
|
||||||
const [globalFilter, setGlobalFilter] = useState(searchQuery)
|
|
||||||
|
|
||||||
const [statusFilter, setStatusFilter] = useState('all')
|
const [showDetailModal, setShowDetailModal] = React.useState(false);
|
||||||
const [cityFilter, setCityFilter] = useState('all')
|
const [showNewUserModal, setShowNewUserModal] = React.useState(false);
|
||||||
const [skillsFilter, setSkillsFilter] = useState<string[]>([])
|
const [selectedUser, setSelectedUser] = React.useState<UserType | null>(null);
|
||||||
|
const [globalFilter, setGlobalFilter] = React.useState(searchQuery);
|
||||||
|
const [statusFilter, setStatusFilter] = React.useState('all');
|
||||||
|
const [skillsFilter, setSkillsFilter] = React.useState<string[]>([]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: usersResponse,
|
data: usersResponse,
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: [
|
queryKey: ['admin-users', currentPage, perPage, statusFilter, searchQuery],
|
||||||
'admin-users',
|
|
||||||
currentPage,
|
|
||||||
perPage,
|
|
||||||
cityFilter,
|
|
||||||
statusFilter,
|
|
||||||
searchQuery,
|
|
||||||
],
|
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
getAdminUsers({
|
getAdminUsers({
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
@@ -89,12 +64,12 @@ function HackathonUsersPage() {
|
|||||||
}),
|
}),
|
||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
gcTime: 5 * 60 * 1000,
|
gcTime: 5 * 60 * 1000,
|
||||||
})
|
});
|
||||||
|
|
||||||
const totalData = usersResponse?.meta?.total_data || 0
|
const totalData = usersResponse?.meta?.total_data || 0;
|
||||||
const totalPages = usersResponse?.meta?.total_page || 1
|
const totalPages = usersResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
const handlePageChange = useCallback(
|
const handlePageChange = React.useCallback(
|
||||||
(newPage: number) => {
|
(newPage: number) => {
|
||||||
navigate({
|
navigate({
|
||||||
search: {
|
search: {
|
||||||
@@ -102,112 +77,73 @@ function HackathonUsersPage() {
|
|||||||
per_page: perPage !== 10 ? perPage : undefined,
|
per_page: perPage !== 10 ? perPage : undefined,
|
||||||
search: searchQuery || undefined,
|
search: searchQuery || undefined,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
});
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
},
|
},
|
||||||
[navigate, perPage, searchQuery]
|
[navigate, perPage, searchQuery]
|
||||||
)
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||||
navigate({ search: { page: totalPages } as any })
|
navigate({ search: { page: totalPages } as any });
|
||||||
}
|
}
|
||||||
}, [currentPage, totalPages, navigate, isLoading])
|
}, [currentPage, totalPages, navigate, isLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
setGlobalFilter(searchQuery)
|
setGlobalFilter(searchQuery);
|
||||||
}, [searchQuery])
|
}, [searchQuery]);
|
||||||
|
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = React.useCallback(() => {
|
||||||
navigate({
|
navigate({
|
||||||
search: {
|
search: {
|
||||||
page: 1,
|
page: 1,
|
||||||
per_page: perPage !== 10 ? perPage : undefined,
|
per_page: perPage !== 10 ? perPage : undefined,
|
||||||
search: globalFilter.trim() || undefined,
|
search: globalFilter.trim() || undefined,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
});
|
||||||
}, [globalFilter, navigate, perPage])
|
}, [globalFilter, navigate, perPage]);
|
||||||
|
|
||||||
const handleSearchKeyPress = useCallback(
|
const handleShowDetailModal = React.useCallback((user: UserType) => {
|
||||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
setSelectedUser(user);
|
||||||
if (e.key === 'Enter') {
|
setShowDetailModal(true);
|
||||||
handleSearch()
|
}, []);
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleSearch]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handlePerPageChange = useCallback(
|
const filteredData = React.useMemo(() => {
|
||||||
(newPerPage: number) => {
|
const usersData: UserType[] =
|
||||||
navigate({
|
((usersResponse?.data as any)?.data as UserType[]) ??
|
||||||
search: {
|
(usersResponse?.data as UserType[]) ??
|
||||||
page: 1,
|
[];
|
||||||
per_page: newPerPage,
|
return usersData.filter((user) => {
|
||||||
search: searchQuery || undefined,
|
|
||||||
} as any,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[navigate, searchQuery]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleShowDetailModal = useCallback((user: UserType) => {
|
|
||||||
setSelectedUser(user)
|
|
||||||
setShowDetailModal(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleCloseDetailModal = useCallback(() => {
|
|
||||||
setShowDetailModal(false)
|
|
||||||
setSelectedUser(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleShowNewUserModal = useCallback(() => {
|
|
||||||
setShowNewUserModal(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleCloseNewUserModal = useCallback(() => {
|
|
||||||
setShowNewUserModal(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
|
||||||
const usersData = usersResponse?.data?.data || usersResponse?.data || []
|
|
||||||
return usersData.filter((user: UserType) => {
|
|
||||||
if (statusFilter !== 'all') {
|
if (statusFilter !== 'all') {
|
||||||
const isActive = statusFilter === 'active'
|
const isActive = statusFilter === 'active';
|
||||||
if (user.is_active !== isActive) return false
|
if (user.is_active !== isActive) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (skillsFilter.length > 0) {
|
if (skillsFilter.length > 0) {
|
||||||
const userSkills = user.skills || []
|
const userSkills = user.skills || [];
|
||||||
const hasMatchingSkill = skillsFilter.some((skill) =>
|
const hasMatchingSkill = skillsFilter.some((skill) =>
|
||||||
userSkills.includes(skill)
|
userSkills.includes(skill)
|
||||||
)
|
);
|
||||||
if (!hasMatchingSkill) return false
|
if (!hasMatchingSkill) return false;
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [usersResponse, statusFilter, skillsFilter]);
|
||||||
|
|
||||||
return true
|
const columns: ColumnDef<UserType>[] = React.useMemo(
|
||||||
})
|
|
||||||
}, [usersResponse, statusFilter, skillsFilter])
|
|
||||||
|
|
||||||
const columns: ColumnDef<UserType>[] = useMemo(
|
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'fullname',
|
accessorKey: 'fullname',
|
||||||
header: 'User',
|
header: 'User',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
|
<Avatar className="size-9">
|
||||||
{row.original.avatar ? (
|
<AvatarImage src={row.original.avatar ?? undefined} alt={row.original.fullname} />
|
||||||
<img
|
<AvatarFallback>
|
||||||
src={row.original.avatar}
|
<User className="size-4 text-muted-foreground" />
|
||||||
alt={row.original.fullname}
|
</AvatarFallback>
|
||||||
className="w-full h-full object-cover"
|
</Avatar>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<UserOutlined className="text-neutral-500 text-lg" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-medium text-neutral-900 truncate">
|
<p className="truncate font-medium text-foreground">
|
||||||
{row.original.fullname}
|
{row.original.fullname}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,30 +155,22 @@ function HackathonUsersPage() {
|
|||||||
accessorKey: 'skills',
|
accessorKey: 'skills',
|
||||||
header: 'Skills',
|
header: 'Skills',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const skills = row.original.skills || []
|
const skills = row.original.skills || [];
|
||||||
|
if (skills.length === 0) {
|
||||||
|
return <span className="text-muted-foreground">-</span>;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1 max-w-xs">
|
<div className="flex flex-wrap gap-1">
|
||||||
{skills.length > 0 ? (
|
{skills.slice(0, 2).map((skill, index) => (
|
||||||
<>
|
<Badge key={index} variant="success">
|
||||||
{skills.slice(0, 2).map((skill, index) => (
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||||
<span
|
</Badge>
|
||||||
key={index}
|
))}
|
||||||
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
|
{skills.length > 2 && (
|
||||||
>
|
<Badge variant="secondary">+{skills.length - 2}</Badge>
|
||||||
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{skills.length > 2 && (
|
|
||||||
<span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700">
|
|
||||||
+{skills.length - 2}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-neutral-400">-</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
},
|
},
|
||||||
@@ -250,7 +178,7 @@ function HackathonUsersPage() {
|
|||||||
accessorKey: 'location',
|
accessorKey: 'location',
|
||||||
header: 'Location',
|
header: 'Location',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-neutral-700">{row.original.location}</span>
|
<span className="text-foreground">{row.original.location}</span>
|
||||||
),
|
),
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
},
|
},
|
||||||
@@ -259,16 +187,16 @@ function HackathonUsersPage() {
|
|||||||
header: 'Status',
|
header: 'Status',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-2 h-2 rounded-full',
|
'size-2 rounded-full',
|
||||||
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
|
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-sm font-medium',
|
'text-sm font-medium',
|
||||||
row.original.is_active ? 'text-success-700' : 'text-neutral-500'
|
row.original.is_active ? 'text-success-700' : 'text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{row.original.is_active ? 'Active' : 'Inactive'}
|
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||||
@@ -277,18 +205,18 @@ function HackathonUsersPage() {
|
|||||||
),
|
),
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
sortingFn: (rowA, rowB) => {
|
sortingFn: (rowA, rowB) => {
|
||||||
const aActive = rowA.original.is_active
|
const a = rowA.original.is_active;
|
||||||
const bActive = rowB.original.is_active
|
const b = rowB.original.is_active;
|
||||||
if (aActive && !bActive) return -1
|
if (a && !b) return -1;
|
||||||
if (!aActive && bActive) return 1
|
if (!a && b) return 1;
|
||||||
return 0
|
return 0;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'created_at',
|
accessorKey: 'created_at',
|
||||||
header: 'Joined',
|
header: 'Joined',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-neutral-900 text-sm">
|
<span className="text-sm text-foreground">
|
||||||
{new Date(row.original.created_at).toLocaleDateString('en-US', {
|
{new Date(row.original.created_at).toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -302,194 +230,135 @@ function HackathonUsersPage() {
|
|||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
meta: { cellClassName: cn('w-48') },
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-2">
|
<Button
|
||||||
<Button
|
variant="secondary"
|
||||||
variant="primary"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => handleShowDetailModal(row.original)}
|
||||||
className="flex items-center gap-2 text-sm px-4 py-2"
|
>
|
||||||
onClick={() => handleShowDetailModal(row.original)}
|
<Pencil className="size-3.5" />
|
||||||
>
|
Manage
|
||||||
<EditOutlined className="text-sm" />
|
</Button>
|
||||||
Manage
|
|
||||||
</Button>
|
|
||||||
{
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[handleShowDetailModal]
|
[handleShowDetailModal]
|
||||||
)
|
);
|
||||||
|
|
||||||
|
const hasActiveFilters = statusFilter !== 'all' || skillsFilter.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
<BackofficeWrapper
|
||||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
title="Hackathon Users"
|
||||||
User Management
|
description="IMPHNEN x Kolosal.ai Hackathon 2025"
|
||||||
</h1>
|
>
|
||||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
<Card>
|
||||||
<div className="flex flex-wrap gap-3 items-center justify-between">
|
<CardHeader>
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="relative">
|
<div className="relative w-full sm:max-w-sm">
|
||||||
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<input
|
<Input
|
||||||
type="text"
|
className="pl-9"
|
||||||
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
placeholder="Cari nama atau lokasi…"
|
||||||
placeholder="Search users by name or location..."
|
|
||||||
value={globalFilter}
|
value={globalFilter}
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
onKeyPress={handleSearchKeyPress}
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Button onClick={() => setShowNewUserModal(true)} size="md">
|
||||||
<div className="relative">
|
<Plus className="size-4" />
|
||||||
<select
|
|
||||||
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
|
||||||
value={perPage}
|
|
||||||
onChange={(e) =>
|
|
||||||
handlePerPageChange(parseInt(e.target.value, 10))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value={10}>10 / page</option>
|
|
||||||
<option value={20}>20 / page</option>
|
|
||||||
<option value={50}>50 / page</option>
|
|
||||||
<option value={100}>100 / page</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{}
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
{}
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
{}
|
|
||||||
{
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
className="flex items-center gap-2 px-4 py-2"
|
|
||||||
onClick={handleShowNewUserModal}
|
|
||||||
>
|
|
||||||
<PlusOutlined className="text-sm" />
|
|
||||||
Add User
|
Add User
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
{(skillsFilter.length > 0 ||
|
{hasActiveFilters && (
|
||||||
statusFilter !== 'all' ||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
cityFilter !== 'all') && (
|
<span className="text-sm text-muted-foreground">
|
||||||
<div className="flex flex-wrap gap-2 items-center">
|
Active filters:
|
||||||
<span className="text-sm text-neutral-600">Active filters:</span>
|
|
||||||
|
|
||||||
{statusFilter !== 'all' && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
|
||||||
Status: {statusFilter}
|
|
||||||
<button
|
|
||||||
onClick={() => setStatusFilter('all')}
|
|
||||||
className="text-info-600 hover:text-info-800 cursor-pointer"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
{statusFilter !== 'all' && (
|
||||||
|
<Badge variant="info" className="gap-1">
|
||||||
{cityFilter !== 'all' && (
|
Status: {statusFilter}
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
<button
|
||||||
City: {cityFilter}
|
onClick={() => setStatusFilter('all')}
|
||||||
<button
|
aria-label="Clear status filter"
|
||||||
onClick={() => setCityFilter('all')}
|
>
|
||||||
className="text-green-600 hover:text-green-800 cursor-pointer"
|
<X className="size-3" />
|
||||||
>
|
</button>
|
||||||
✕
|
</Badge>
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{skillsFilter.map((skill) => (
|
|
||||||
<span
|
|
||||||
key={skill}
|
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
|
|
||||||
>
|
|
||||||
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
|
|
||||||
}
|
|
||||||
className="text-purple-600 hover:text-purple-800 cursor-pointer"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setStatusFilter('all')
|
|
||||||
setCityFilter('all')
|
|
||||||
setSkillsFilter([])
|
|
||||||
setGlobalFilter('')
|
|
||||||
}}
|
|
||||||
className="text-sm text-neutral-600"
|
|
||||||
>
|
|
||||||
Clear All
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
|
||||||
<span className="ml-3 text-neutral-600">Loading users...</span>
|
|
||||||
</div>
|
|
||||||
) : filteredData.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="text-sm text-neutral-600">
|
|
||||||
Showing {filteredData.length} of {totalData} users (Page{' '}
|
|
||||||
{currentPage} of {totalPages})
|
|
||||||
{isFetching && (
|
|
||||||
<span className="ml-2 text-primary-500">(Updating...)</span>
|
|
||||||
)}
|
)}
|
||||||
|
{skillsFilter.map((skill) => (
|
||||||
|
<Badge key={skill} variant="secondary" className="gap-1">
|
||||||
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
|
||||||
|
}
|
||||||
|
aria-label={`Clear ${skill} filter`}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter('all');
|
||||||
|
setSkillsFilter([]);
|
||||||
|
setGlobalFilter('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
)}
|
||||||
data={filteredData}
|
{isLoading ? (
|
||||||
columns={columns}
|
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||||
pageSize={perPage}
|
<span>Memuat data users…</span>
|
||||||
manualPagination={true}
|
</div>
|
||||||
pageCount={totalPages}
|
) : filteredData.length > 0 ? (
|
||||||
currentPage={currentPage}
|
<>
|
||||||
onPageChange={handlePageChange}
|
<div className="text-xs text-muted-foreground">
|
||||||
/>
|
Menampilkan {filteredData.length} dari {totalData} users (page{' '}
|
||||||
</>
|
{currentPage} / {totalPages})
|
||||||
) : (
|
{isFetching && (
|
||||||
<div className="text-center py-12 text-neutral-500">
|
<span className="ml-2 text-primary-500">Updating…</span>
|
||||||
No users found. Try adjusting your filters.
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<DataTable
|
||||||
</section>
|
data={filteredData}
|
||||||
|
columns={columns}
|
||||||
|
pageSize={perPage}
|
||||||
|
manualPagination
|
||||||
|
pageCount={totalPages}
|
||||||
|
currentPage={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
|
Tidak ada user. Coba ubah filter pencarian.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<ModalUserDetail
|
<ModalUserDetail
|
||||||
isOpen={showDetailModal}
|
isOpen={showDetailModal}
|
||||||
onClose={handleCloseDetailModal}
|
onClose={() => {
|
||||||
|
setShowDetailModal(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
}}
|
||||||
user={selectedUser}
|
user={selectedUser}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModalUserDetail
|
<ModalUserDetail
|
||||||
isOpen={showNewUserModal}
|
isOpen={showNewUserModal}
|
||||||
onClose={handleCloseNewUserModal}
|
onClose={() => setShowNewUserModal(false)}
|
||||||
user={null}
|
user={null}
|
||||||
/>
|
/>
|
||||||
</BackofficeWrapper>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { Fragment, useState } from 'react'
|
import * as React from 'react';
|
||||||
|
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
Button,
|
||||||
EditOutlined,
|
Card,
|
||||||
DeleteOutlined,
|
CardContent,
|
||||||
PlusOutlined,
|
CardHeader,
|
||||||
} from '@ant-design/icons'
|
Input,
|
||||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
|
import {
|
||||||
|
DataTable,
|
||||||
|
BackofficeWrapper,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -15,144 +19,101 @@ import {
|
|||||||
PaginationState,
|
PaginationState,
|
||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
usePermissionList,
|
usePermissionList,
|
||||||
useDeletePermission,
|
useDeletePermission,
|
||||||
TPermissionItem,
|
TPermissionItem,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
import React from 'react'
|
import { toast } from 'sonner';
|
||||||
import { toast } from 'sonner'
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
DeleteConfirmDialog,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/permissions')({
|
export const Route = createFileRoute('/_authenticated/permissions')({
|
||||||
component: PermissionsPage,
|
component: PermissionsPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function PermissionsPage() {
|
function PermissionsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = React.useState('');
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
|
|
||||||
const { data: permissionsData, isLoading } = usePermissionList({
|
const { data: permissionsData, isLoading } = usePermissionList({
|
||||||
search,
|
search,
|
||||||
page: pagination.pageIndex + 1,
|
page: pagination.pageIndex + 1,
|
||||||
per_page: pagination.pageSize,
|
per_page: pagination.pageSize,
|
||||||
})
|
});
|
||||||
const deletePermission = useDeletePermission()
|
const deletePermission = useDeletePermission();
|
||||||
|
|
||||||
const permissions: TPermissionItem[] = permissionsData?.data ?? []
|
const permissions: TPermissionItem[] = permissionsData?.data ?? [];
|
||||||
const totalItems = permissionsData?.meta?.total ?? permissions.length
|
const totalItems = permissionsData?.meta?.total ?? permissions.length;
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deletePermission.mutateAsync(id)
|
await deletePermission.mutateAsync(id);
|
||||||
toast.success('Data permissions berhasil dihapus')
|
toast.success('Data permission berhasil dihapus');
|
||||||
setDeleteId(null)
|
setDeleteId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
toast.error('Data permissions gagal dihapus')
|
toast.error('Data permission gagal dihapus');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<TPermissionItem>[] = [
|
const columns: ColumnDef<TPermissionItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
header: ({ table }) => (
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
<input
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={table.getIsAllRowsSelected()}
|
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onChange={row.getToggleSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'No',
|
|
||||||
accessorKey: 'id',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Name',
|
|
||||||
accessorKey: 'name',
|
|
||||||
},
|
},
|
||||||
|
{ header: 'No', accessorKey: 'id' },
|
||||||
|
{ header: 'Name', accessorKey: 'name' },
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex gap-[8px]">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
navigate({ to: '/permissions/$id', params: { id: row.original.id } })
|
navigate({
|
||||||
|
to: '/permissions/$id',
|
||||||
|
params: { id: row.original.id },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<EditOutlined /> Update
|
<Pencil className="size-3.5" />
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeleteId(row.original.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
{deleteId === row.original.id ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-label2 text-neutral-500">Yakin?</span>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleDelete(row.original.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Ya
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setDeleteId(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setDeleteId(row.original.id)
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<DeleteOutlined /> Delete
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: permissions,
|
data: permissions,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -160,53 +121,61 @@ function PermissionsPage() {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<BackofficeWrapper
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
title="Permissions"
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
description="Kelola hak akses sistem"
|
||||||
<h1 className="text-p2 font-semibold">Permissions</h1>
|
>
|
||||||
</header>
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex justify-between items-center gap-8 mb-2">
|
<div className="relative w-full sm:max-w-sm">
|
||||||
<div className="relative w-full">
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Cari berdasarkan nama permissions"
|
placeholder="Cari nama permission…"
|
||||||
className="pl-12 w-full max-h-full"
|
className="pl-9"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
|
||||||
<SearchOutlined />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
className="flex gap-3 text-nowrap"
|
|
||||||
onClick={() => navigate({ to: '/permissions/create' })}
|
|
||||||
>
|
|
||||||
<PlusOutlined />
|
|
||||||
Tambah Permissions
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate({ to: '/permissions/create' })}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Tambah Permission
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Memuat data…
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DataTable
|
<DataTable
|
||||||
data={permissions}
|
data={permissions}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
pageSize={9}
|
|
||||||
table={table}
|
table={table}
|
||||||
|
manualPagination
|
||||||
|
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||||
|
currentPage={pagination.pageIndex + 1}
|
||||||
|
onPageChange={(p) =>
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardContent>
|
||||||
</main>
|
</Card>
|
||||||
</Fragment>
|
|
||||||
)
|
<DeleteConfirmDialog
|
||||||
|
open={!!deleteId}
|
||||||
|
onOpenChange={(o) => !o && setDeleteId(null)}
|
||||||
|
onConfirm={() => deleteId && handleDelete(deleteId)}
|
||||||
|
title="Hapus permission ini?"
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import * as React from 'react'
|
import * as React from 'react';
|
||||||
import { FC, Fragment, ReactElement, useState } from 'react'
|
import { Filter as FilterIcon, Search, ClipboardCheck } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
FilterOutlined,
|
Badge,
|
||||||
SearchOutlined,
|
Button,
|
||||||
AuditOutlined,
|
Card,
|
||||||
} from '@ant-design/icons'
|
CardContent,
|
||||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
CardHeader,
|
||||||
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms'
|
Input,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
Filter,
|
||||||
|
BackofficeWrapper,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -15,19 +24,23 @@ import {
|
|||||||
PaginationState,
|
PaginationState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table';
|
||||||
import ModalProcessDelivery from './_components/prizes/modal-process-item'
|
import ModalProcessDelivery from './_components/prizes/modal-process-item';
|
||||||
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
type OrderValid = 'valid' | 'invalid' | 'unchecked'
|
type OrderValid = 'valid' | 'invalid' | 'unchecked';
|
||||||
type Status = 'undelivered' | 'delivered'
|
type Status = 'undelivered' | 'delivered';
|
||||||
|
|
||||||
interface Prize {
|
interface Prize {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
orderValid: OrderValid
|
orderValid: OrderValid;
|
||||||
items: string
|
items: string;
|
||||||
address: string
|
address: string;
|
||||||
status: Status
|
status: Status;
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
@@ -37,7 +50,7 @@ const items = [
|
|||||||
'Sticker Isi 3',
|
'Sticker Isi 3',
|
||||||
'Sticker Isi 5',
|
'Sticker Isi 5',
|
||||||
'Gelang Karet',
|
'Gelang Karet',
|
||||||
]
|
];
|
||||||
|
|
||||||
const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
|
const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
id: i + 1,
|
id: i + 1,
|
||||||
@@ -45,141 +58,108 @@ const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
|
|||||||
orderValid: (i % 3 === 0
|
orderValid: (i % 3 === 0
|
||||||
? 'invalid'
|
? 'invalid'
|
||||||
: i % 5 === 0
|
: i % 5 === 0
|
||||||
? 'unchecked'
|
? 'unchecked'
|
||||||
: 'valid') as OrderValid,
|
: 'valid') as OrderValid,
|
||||||
items: items[i % items.length],
|
items: items[i % items.length],
|
||||||
address: 'Jl. Pantai Cibaduyut Indah',
|
address: 'Jl. Pantai Cibaduyut Indah',
|
||||||
status: (i % 3 === 0 ? 'undelivered' : 'delivered') as Status,
|
status: (i % 3 === 0 ? 'undelivered' : 'delivered') as Status,
|
||||||
}))
|
}));
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/prizes')({
|
export const Route = createFileRoute('/_authenticated/prizes')({
|
||||||
component: PrizesPage,
|
component: PrizesPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function PrizesPage() {
|
function PrizesPage() {
|
||||||
const [showModalProcessDelivery, setShowModalProcessDelivery] =
|
const [showModalProcessDelivery, setShowModalProcessDelivery] =
|
||||||
useState(false)
|
React.useState(false);
|
||||||
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
const [showFilter, setShowFilter] = useState(false)
|
const [showFilter, setShowFilter] = React.useState(false);
|
||||||
|
|
||||||
const deliveryOptions = [
|
const deliveryOptions = [
|
||||||
{ id: 'option1', value: 'undelivered', label: 'Undelivered' },
|
{ id: 'undelivered', value: 'undelivered', label: 'Undelivered' },
|
||||||
{ id: 'option1', value: 'delivered', label: 'Delivered' },
|
{ id: 'delivered', value: 'delivered', label: 'Delivered' },
|
||||||
]
|
];
|
||||||
|
|
||||||
|
const orderValidVariants: Record<
|
||||||
|
OrderValid,
|
||||||
|
'success' | 'destructive' | 'warning'
|
||||||
|
> = {
|
||||||
|
valid: 'success',
|
||||||
|
invalid: 'destructive',
|
||||||
|
unchecked: 'warning',
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderValidText: Record<OrderValid, string> = {
|
||||||
|
valid: 'Valid',
|
||||||
|
invalid: 'Invalid',
|
||||||
|
unchecked: 'Unchecked',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusVariants: Record<Status, 'success' | 'destructive'> = {
|
||||||
|
delivered: 'success',
|
||||||
|
undelivered: 'destructive',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusText: Record<Status, string> = {
|
||||||
|
delivered: 'Delivered',
|
||||||
|
undelivered: 'Undelivered',
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<Prize>[] = [
|
const columns: ColumnDef<Prize>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
header: ({ table }) => (
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
<input
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={table.getIsAllRowsSelected()}
|
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onChange={row.getToggleSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'No',
|
|
||||||
accessorKey: 'id',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Nama Lengkap',
|
|
||||||
accessorKey: 'name',
|
|
||||||
},
|
},
|
||||||
|
{ header: 'No', accessorKey: 'id' },
|
||||||
|
{ header: 'Nama Lengkap', accessorKey: 'name' },
|
||||||
{
|
{
|
||||||
header: 'Order Valid?',
|
header: 'Order Valid?',
|
||||||
accessorKey: 'orderValid',
|
accessorKey: 'orderValid',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const status = row.original.orderValid
|
<Badge variant={orderValidVariants[row.original.orderValid]}>
|
||||||
const statusColors: Record<OrderValid, string> = {
|
{orderValidText[row.original.orderValid]}
|
||||||
valid: 'bg-success-200 text-success-500',
|
</Badge>
|
||||||
invalid: 'bg-danger-200 text-danger-500',
|
),
|
||||||
unchecked: 'bg-warning-200 text-warning-900',
|
|
||||||
}
|
|
||||||
const statusText: Record<OrderValid, string> = {
|
|
||||||
valid: 'Valid',
|
|
||||||
invalid: 'Invalid',
|
|
||||||
unchecked: 'Unchecked',
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
|
||||||
>
|
|
||||||
{statusText[status]}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Items',
|
|
||||||
accessorKey: 'items',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Alamat Pengiriman',
|
|
||||||
accessorKey: 'address',
|
|
||||||
},
|
},
|
||||||
|
{ header: 'Items', accessorKey: 'items' },
|
||||||
|
{ header: 'Alamat Pengiriman', accessorKey: 'address' },
|
||||||
{
|
{
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const status = row.original.status
|
<Badge variant={statusVariants[row.original.status]}>
|
||||||
const statusColors: Record<Status, string> = {
|
{statusText[row.original.status]}
|
||||||
delivered: 'bg-success-200 text-success-500',
|
</Badge>
|
||||||
undelivered: 'bg-danger-200 text-danger-500',
|
),
|
||||||
}
|
|
||||||
const statusText: Record<Status, string> = {
|
|
||||||
delivered: 'Delivered',
|
|
||||||
undelivered: 'Undelivered',
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
|
||||||
>
|
|
||||||
{statusText[status]}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
cell: ({ row }) => (
|
cell: () => (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setShowModalProcessDelivery(true)
|
setShowModalProcessDelivery(true);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 w-full"
|
|
||||||
>
|
>
|
||||||
<AuditOutlined className="text-[16px]" /> Process
|
<ClipboardCheck className="size-3.5" />
|
||||||
|
Process
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: mockData,
|
data: mockData,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -187,59 +167,55 @@ function PrizesPage() {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
manualPagination: false,
|
manualPagination: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<BackofficeWrapper
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
title="Data Pengiriman Hadiah"
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
description="Proses pengiriman hadiah ke pemenang"
|
||||||
<h1 className="text-p2 font-semibold">Data Pengiriman Hadiah</h1>
|
>
|
||||||
</header>
|
<Card>
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
<CardHeader>
|
||||||
<div className="flex justify-between items-center gap-8 mb-2">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="relative w-full">
|
<div className="relative w-full sm:max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Cari berdasarkan nama lengkap, nomor order Shopee"
|
className="pl-9"
|
||||||
className="pl-12 w-full max-h-full"
|
placeholder="Cari nama atau nomor order…"
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
|
||||||
<SearchOutlined />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
className="flex items-center gap-3"
|
|
||||||
onClick={() => setShowFilter(!showFilter)}
|
|
||||||
>
|
|
||||||
<FilterOutlined />
|
|
||||||
Filters
|
|
||||||
</Button>
|
|
||||||
{showFilter && (
|
|
||||||
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
|
|
||||||
<Filter
|
|
||||||
options={deliveryOptions}
|
|
||||||
onClose={() => setShowFilter(false)}
|
|
||||||
onFilterChange={(value) => {
|
|
||||||
console.log('Selected filter:', value)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<Popover open={showFilter} onOpenChange={setShowFilter}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="secondary" size="md">
|
||||||
|
<FilterIcon className="size-4" />
|
||||||
|
Filters
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-auto p-0">
|
||||||
|
<Filter
|
||||||
|
options={deliveryOptions}
|
||||||
|
title="Status"
|
||||||
|
onClose={() => setShowFilter(false)}
|
||||||
|
onFilterChange={(value) => {
|
||||||
|
console.log('Selected filter:', value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
<DataTable data={mockData} columns={columns} table={table} />
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
</section>
|
</CardContent>
|
||||||
</main>
|
</Card>
|
||||||
|
|
||||||
<ModalProcessDelivery
|
<ModalProcessDelivery
|
||||||
isOpen={showModalProcessDelivery}
|
isOpen={showModalProcessDelivery}
|
||||||
onClose={() => setShowModalProcessDelivery(false)}
|
onClose={() => setShowModalProcessDelivery(false)}
|
||||||
handleProcessDelivery={() => {
|
handleProcessDelivery={() => {
|
||||||
console.log('Action ketika user menekan tombol Proses Pengiriman')
|
console.log('Action proses pengiriman');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Fragment>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,182 +1,162 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons'
|
import * as React from 'react';
|
||||||
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms'
|
import { Search, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||||
import { BackofficeWrapper, DataTable } from '@imphnen-frontend-service/ui/organisms'
|
import {
|
||||||
import { cn } from '@imphnen-frontend-service/utils'
|
Badge,
|
||||||
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from '@tanstack/react-table'
|
Button,
|
||||||
import { useState } from 'react'
|
Card,
|
||||||
import { toast } from 'sonner'
|
CardContent,
|
||||||
import { useRoadmapList, useDeleteRoadmap, TRoadmapListItem, TRoadmapStatus } from '@imphnen-frontend-service/service'
|
CardHeader,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
RowSelectionState,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
useRoadmapList,
|
||||||
|
useDeleteRoadmap,
|
||||||
|
TRoadmapListItem,
|
||||||
|
TRoadmapStatus,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
DeleteConfirmDialog,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/roadmap-dimentorin')({
|
export const Route = createFileRoute('/_authenticated/roadmap-dimentorin')({
|
||||||
component: RoadmapDimentorinPage,
|
component: RoadmapDimentorinPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function RoadmapDimentorinPage(): React.ReactElement {
|
function RoadmapDimentorinPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = React.useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('')
|
const [statusFilter, setStatusFilter] = React.useState('all');
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
const [deletingId, setDeletingId] = React.useState<string | null>(null);
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: roadmapData, isLoading } = useRoadmapList()
|
const { data: roadmapData, isLoading } = useRoadmapList();
|
||||||
const deleteRoadmap = useDeleteRoadmap()
|
const deleteRoadmap = useDeleteRoadmap();
|
||||||
|
|
||||||
const allItems: TRoadmapListItem[] = roadmapData ?? []
|
const allItems: TRoadmapListItem[] = roadmapData ?? [];
|
||||||
const filteredItems = allItems.filter((item) => {
|
const filteredItems = allItems.filter((item) => {
|
||||||
const matchSearch = !search || item.title.toLowerCase().includes(search.toLowerCase())
|
const matchSearch =
|
||||||
const matchStatus = !statusFilter || item.status === statusFilter
|
!search || item.title.toLowerCase().includes(search.toLowerCase());
|
||||||
return matchSearch && matchStatus
|
const matchStatus = statusFilter === 'all' || item.status === statusFilter;
|
||||||
})
|
return matchSearch && matchStatus;
|
||||||
|
});
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteRoadmap.mutateAsync(id)
|
await deleteRoadmap.mutateAsync(id);
|
||||||
toast.success('Roadmap berhasil dihapus')
|
toast.success('Roadmap berhasil dihapus');
|
||||||
setDeletingId(null)
|
setDeletingId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
toast.error('Gagal menghapus roadmap')
|
toast.error('Gagal menghapus roadmap');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const statusColors: Record<TRoadmapStatus, string> = {
|
const statusVariants: Record<
|
||||||
upcoming: 'bg-warning-200 text-warning-700',
|
TRoadmapStatus,
|
||||||
in_progress: 'bg-primary-200 text-primary-700',
|
'warning' | 'info' | 'success'
|
||||||
completed: 'bg-success-200 text-success-500',
|
> = {
|
||||||
}
|
upcoming: 'warning',
|
||||||
|
in_progress: 'info',
|
||||||
|
completed: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
const statusText: Record<TRoadmapStatus, string> = {
|
const statusText: Record<TRoadmapStatus, string> = {
|
||||||
upcoming: 'Upcoming',
|
upcoming: 'Upcoming',
|
||||||
in_progress: 'In Progress',
|
in_progress: 'In Progress',
|
||||||
completed: 'Completed',
|
completed: 'Completed',
|
||||||
}
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<TRoadmapListItem>[] = [
|
const columns: ColumnDef<TRoadmapListItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
meta: { cellClassName: cn('w-20') },
|
meta: { cellClassName: cn('w-10') },
|
||||||
header: ({ table }) => (
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
<input
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={table.getIsAllRowsSelected()}
|
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onChange={row.getToggleSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'title',
|
|
||||||
header: 'Title',
|
|
||||||
accessorKey: 'title',
|
|
||||||
},
|
},
|
||||||
|
{ id: 'title', header: 'Title', accessorKey: 'title' },
|
||||||
{
|
{
|
||||||
id: 'description',
|
id: 'description',
|
||||||
header: 'Description',
|
header: 'Description',
|
||||||
accessorKey: 'description',
|
accessorKey: 'description',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="line-clamp-2">{row.original.description}</span>
|
<span className="line-clamp-2 max-w-md">{row.original.description}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'status',
|
id: 'status',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const status = row.original.status
|
<Badge variant={statusVariants[row.original.status] ?? 'secondary'}>
|
||||||
return (
|
{statusText[row.original.status] ?? row.original.status}
|
||||||
<div className={`py-2 px-4 rounded-md text-center ${statusColors[status] ?? 'bg-neutral-200 text-neutral-700'}`}>
|
</Badge>
|
||||||
{statusText[status] ?? status}
|
),
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'votes',
|
|
||||||
header: 'Votes',
|
|
||||||
accessorKey: 'votes',
|
|
||||||
},
|
},
|
||||||
|
{ id: 'votes', header: 'Votes', accessorKey: 'votes' },
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
meta: { cellClassName: cn('w-72') },
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex gap-[8px]">
|
<div className="flex items-center gap-2">
|
||||||
{deletingId === row.original.id ? (
|
<Button
|
||||||
<>
|
variant="secondary"
|
||||||
<Button
|
size="sm"
|
||||||
variant="danger"
|
onClick={(e) => {
|
||||||
size="sm"
|
e.stopPropagation();
|
||||||
onClick={(e) => {
|
navigate({
|
||||||
e.stopPropagation()
|
to: '/roadmap-dimentorin/$id',
|
||||||
handleDelete(row.original.id)
|
params: { id: row.original.id },
|
||||||
}}
|
});
|
||||||
className="flex items-center gap-2"
|
}}
|
||||||
>
|
>
|
||||||
Konfirmasi
|
<Pencil className="size-3.5" />
|
||||||
</Button>
|
Edit
|
||||||
<Button
|
</Button>
|
||||||
variant="bordered"
|
<Button
|
||||||
size="sm"
|
variant="danger"
|
||||||
onClick={(e) => {
|
size="sm"
|
||||||
e.stopPropagation()
|
onClick={(e) => {
|
||||||
setDeletingId(null)
|
e.stopPropagation();
|
||||||
}}
|
setDeletingId(row.original.id);
|
||||||
className="flex items-center gap-2"
|
}}
|
||||||
>
|
>
|
||||||
Batal
|
<Trash2 className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
navigate({ to: '/roadmap-dimentorin/$id', params: { id: row.original.id } })
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<EditOutlined /> Edit
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setDeletingId(row.original.id)
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<DeleteOutlined /> Delete
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: filteredItems,
|
data: filteredItems,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -184,51 +164,64 @@ function RoadmapDimentorinPage(): React.ReactElement {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(filteredItems.length / pagination.pageSize),
|
pageCount: Math.ceil(filteredItems.length / pagination.pageSize),
|
||||||
manualPagination: false,
|
manualPagination: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="Dimentorin.dev">
|
<BackofficeWrapper
|
||||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Content & Roadmap</h1>
|
title="Content & Roadmap"
|
||||||
|
description="Kelola AI roadmap dimentorin"
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
>
|
||||||
<div className="flex items-center justify-between mb-9">
|
<Card>
|
||||||
<h2 className="text-p2 font-semibold text-neutral-600">AI Roadmaps</h2>
|
<CardHeader>
|
||||||
<Button
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
type="button"
|
<div className="flex flex-1 flex-col gap-2 sm:flex-row">
|
||||||
variant="primary"
|
<div className="relative w-full sm:max-w-sm">
|
||||||
className="flex items-center gap-2"
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
onClick={() => navigate({ to: '/roadmap-dimentorin/create' })}
|
<Input
|
||||||
>
|
className="pl-9"
|
||||||
<PlusOutlined /> Buat Roadmap
|
placeholder="Cari judul roadmap…"
|
||||||
</Button>
|
value={search}
|
||||||
</div>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
<div className="flex justify-between items-center gap-5 mb-2">
|
</div>
|
||||||
<div className="relative w-full">
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<Input
|
<SelectTrigger className="w-40">
|
||||||
placeholder="Cari berdasarkan judul roadmap"
|
<SelectValue placeholder="Status" />
|
||||||
className="pl-12 w-full max-h-full"
|
</SelectTrigger>
|
||||||
value={search}
|
<SelectContent>
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
<SelectItem value="all">Semua Status</SelectItem>
|
||||||
/>
|
<SelectItem value="upcoming">Upcoming</SelectItem>
|
||||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||||
<SearchOutlined />
|
<SelectItem value="completed">Completed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
onClick={() => navigate({ to: '/roadmap-dimentorin/create' })}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Buat Roadmap
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
</CardHeader>
|
||||||
<option value="">Semua Status</option>
|
<CardContent>
|
||||||
<option value="upcoming">Upcoming</option>
|
{isLoading ? (
|
||||||
<option value="in_progress">In Progress</option>
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
<option value="completed">Completed</option>
|
Memuat data…
|
||||||
</Select>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<DataTable data={filteredItems} columns={columns} table={table} />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{isLoading ? (
|
<DeleteConfirmDialog
|
||||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
open={!!deletingId}
|
||||||
) : (
|
onOpenChange={(o) => !o && setDeletingId(null)}
|
||||||
<DataTable data={filteredItems} columns={columns} table={table} />
|
onConfirm={() => deletingId && handleDelete(deletingId)}
|
||||||
)}
|
title="Hapus roadmap ini?"
|
||||||
</section>
|
/>
|
||||||
</BackofficeWrapper>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { Fragment, useState } from 'react'
|
import * as React from 'react';
|
||||||
|
import { Search, Pencil, Trash2, Plus } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
Button,
|
||||||
EditOutlined,
|
Card,
|
||||||
DeleteOutlined,
|
CardContent,
|
||||||
PlusOutlined,
|
CardHeader,
|
||||||
} from '@ant-design/icons'
|
Input,
|
||||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { DataTable } from '@imphnen-frontend-service/ui/organisms'
|
import {
|
||||||
|
DataTable,
|
||||||
|
BackofficeWrapper,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -15,144 +19,98 @@ import {
|
|||||||
PaginationState,
|
PaginationState,
|
||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
useRoleList,
|
useRoleList,
|
||||||
useDeleteRole,
|
useDeleteRole,
|
||||||
TRolesListItem,
|
TRolesListItem,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
import React from 'react'
|
import { toast } from 'sonner';
|
||||||
import { toast } from 'sonner'
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
DeleteConfirmDialog,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/roles')({
|
export const Route = createFileRoute('/_authenticated/roles')({
|
||||||
component: RolesPage,
|
component: RolesPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function RolesPage() {
|
function RolesPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = React.useState('');
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
|
|
||||||
const { data: rolesData, isLoading } = useRoleList({
|
const { data: rolesData, isLoading } = useRoleList({
|
||||||
search,
|
search,
|
||||||
page: pagination.pageIndex + 1,
|
page: pagination.pageIndex + 1,
|
||||||
per_page: pagination.pageSize,
|
per_page: pagination.pageSize,
|
||||||
})
|
});
|
||||||
const deleteRole = useDeleteRole()
|
const deleteRole = useDeleteRole();
|
||||||
|
|
||||||
const roles: TRolesListItem[] = rolesData?.data ?? []
|
const roles: TRolesListItem[] = rolesData?.data ?? [];
|
||||||
const totalItems = rolesData?.meta?.total ?? roles.length
|
const totalItems = rolesData?.meta?.total ?? roles.length;
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteRole.mutateAsync(id)
|
await deleteRole.mutateAsync(id);
|
||||||
toast.success('Data role berhasil dihapus')
|
toast.success('Data role berhasil dihapus');
|
||||||
setDeleteId(null)
|
setDeleteId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
toast.error('Data role gagal dihapus')
|
toast.error('Data role gagal dihapus');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<TRolesListItem>[] = [
|
const columns: ColumnDef<TRolesListItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
header: ({ table }) => (
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
<input
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={table.getIsAllRowsSelected()}
|
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onChange={row.getToggleSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'ID',
|
|
||||||
accessorKey: 'id',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Roles Name',
|
|
||||||
accessorKey: 'name',
|
|
||||||
},
|
},
|
||||||
|
{ header: 'ID', accessorKey: 'id' },
|
||||||
|
{ header: 'Roles Name', accessorKey: 'name' },
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex gap-[8px]">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
navigate({ to: '/roles/$id', params: { id: row.original.id } })
|
navigate({ to: '/roles/$id', params: { id: row.original.id } });
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<EditOutlined /> Update
|
<Pencil className="size-3.5" />
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeleteId(row.original.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
{deleteId === row.original.id ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-label2 text-neutral-500">Yakin?</span>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleDelete(row.original.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Ya
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setDeleteId(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setDeleteId(row.original.id)
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<DeleteOutlined /> Delete
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: roles,
|
data: roles,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -160,53 +118,58 @@ function RolesPage() {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<BackofficeWrapper title="Roles" description="Kelola role dan akses">
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
<Card>
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
<CardHeader>
|
||||||
<h1 className="text-p2 font-semibold">Roles</h1>
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
</header>
|
<div className="relative w-full sm:max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
|
||||||
<div className="flex justify-between items-center gap-8 mb-2">
|
|
||||||
<div className="relative w-full">
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Cari berdasarkan nama roles"
|
placeholder="Cari nama role…"
|
||||||
className="pl-12 w-full max-h-full"
|
className="pl-9"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
|
||||||
<SearchOutlined />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
className="flex gap-3 text-nowrap"
|
|
||||||
onClick={() => navigate({ to: '/roles/create' })}
|
|
||||||
>
|
|
||||||
<PlusOutlined />
|
|
||||||
Tambah Role
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate({ to: '/roles/create' })}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Tambah Role
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Memuat data…
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DataTable
|
<DataTable
|
||||||
data={roles}
|
data={roles}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
pageSize={9}
|
|
||||||
table={table}
|
table={table}
|
||||||
|
manualPagination
|
||||||
|
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||||
|
currentPage={pagination.pageIndex + 1}
|
||||||
|
onPageChange={(p) =>
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardContent>
|
||||||
</main>
|
</Card>
|
||||||
</Fragment>
|
|
||||||
)
|
<DeleteConfirmDialog
|
||||||
|
open={!!deleteId}
|
||||||
|
onOpenChange={(o) => !o && setDeleteId(null)}
|
||||||
|
onConfirm={() => deleteId && handleDelete(deleteId)}
|
||||||
|
title="Hapus role ini?"
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +1,82 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { SearchOutlined } from '@ant-design/icons'
|
import * as React from 'react';
|
||||||
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms'
|
import { Search, Eye } from 'lucide-react';
|
||||||
import { BackofficeWrapper, DataTable } from '@imphnen-frontend-service/ui/organisms'
|
import {
|
||||||
import { cn } from '@imphnen-frontend-service/utils'
|
Badge,
|
||||||
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from '@tanstack/react-table'
|
Button,
|
||||||
import { ReactElement, useState } from 'react'
|
Card,
|
||||||
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service'
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
RowSelectionState,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import {
|
||||||
|
useMySessions,
|
||||||
|
TSessionListItem,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/session-dimentorin')({
|
export const Route = createFileRoute('/_authenticated/session-dimentorin')({
|
||||||
component: SessionDimentorinPage,
|
component: SessionDimentorinPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function SessionDimentorinPage(): ReactElement {
|
function SessionDimentorinPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [statusFilter, setStatusFilter] = useState('')
|
const [statusFilter, setStatusFilter] = React.useState('all');
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: sessionsData, isLoading } = useMySessions(
|
const { data: sessionsData, isLoading } = useMySessions(
|
||||||
statusFilter ? { status: statusFilter } : undefined
|
statusFilter !== 'all' ? { status: statusFilter } : undefined
|
||||||
)
|
);
|
||||||
|
|
||||||
const sessions: TSessionListItem[] = sessionsData?.sessions ?? []
|
const sessions: TSessionListItem[] = sessionsData?.sessions ?? [];
|
||||||
const totalItems = sessionsData?.total ?? sessions.length
|
const totalItems = sessionsData?.total ?? sessions.length;
|
||||||
|
|
||||||
|
const statusVariants: Record<
|
||||||
|
string,
|
||||||
|
'warning' | 'info' | 'success' | 'destructive' | 'secondary'
|
||||||
|
> = {
|
||||||
|
pending: 'warning',
|
||||||
|
confirmed: 'info',
|
||||||
|
ongoing: 'warning',
|
||||||
|
completed: 'success',
|
||||||
|
cancelled: 'destructive',
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<TSessionListItem>[] = [
|
const columns: ColumnDef<TSessionListItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
meta: { cellClassName: cn('w-20') },
|
meta: { cellClassName: cn('w-10') },
|
||||||
header: ({ table }) => (
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
<input
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={table.getIsAllRowsSelected()}
|
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onChange={row.getToggleSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'id',
|
|
||||||
header: 'ID Sesi',
|
|
||||||
accessorKey: 'id',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mentorId',
|
|
||||||
header: 'Nama Mentor',
|
|
||||||
accessorKey: 'mentor_id',
|
|
||||||
},
|
},
|
||||||
|
{ id: 'id', header: 'ID Sesi', accessorKey: 'id' },
|
||||||
|
{ id: 'mentorId', header: 'Nama Mentor', accessorKey: 'mentor_id' },
|
||||||
{
|
{
|
||||||
id: 'menteeName',
|
id: 'menteeName',
|
||||||
header: 'Nama Mentee',
|
header: 'Nama Mentee',
|
||||||
@@ -69,55 +87,49 @@ function SessionDimentorinPage(): ReactElement {
|
|||||||
header: 'Waktu',
|
header: 'Waktu',
|
||||||
accessorKey: 'scheduled_at',
|
accessorKey: 'scheduled_at',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span>{new Date(row.original.scheduled_at).toLocaleString('id-ID')}</span>
|
<span>
|
||||||
|
{new Date(row.original.scheduled_at).toLocaleString('id-ID')}
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'status',
|
id: 'status',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const status = row.original.status
|
<Badge
|
||||||
const statusColors: Record<string, string> = {
|
variant={statusVariants[row.original.status] ?? 'secondary'}
|
||||||
pending: 'bg-warning-200 text-warning-700',
|
className="capitalize"
|
||||||
confirmed: 'bg-primary-200 text-primary-700',
|
>
|
||||||
ongoing: 'bg-warning-200 text-warning-700',
|
{row.original.status}
|
||||||
completed: 'bg-success-200 text-success-500',
|
</Badge>
|
||||||
cancelled: 'bg-danger-200 text-danger-500',
|
),
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className={`py-2 px-4 rounded-md text-center capitalize ${statusColors[status] ?? 'bg-neutral-200 text-neutral-700'}`}>
|
|
||||||
{status}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
meta: { cellClassName: cn('w-52') },
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
navigate({ to: '/session-dimentorin/$id', params: { id: row.original.id } })
|
navigate({
|
||||||
|
to: '/session-dimentorin/$id',
|
||||||
|
params: { id: row.original.id },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 w-max"
|
|
||||||
>
|
>
|
||||||
<SearchOutlined className="text-[16px]" /> Cek Detail
|
<Eye className="size-3.5" />
|
||||||
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: sessions,
|
data: sessions,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -125,39 +137,55 @@ function SessionDimentorinPage(): ReactElement {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
pageCount: Math.ceil(totalItems / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="Dimentorin.dev">
|
<BackofficeWrapper
|
||||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Session Management</h1>
|
title="Session Management"
|
||||||
|
description="Kelola sesi mentoring aktif"
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
>
|
||||||
<div className="flex justify-between items-center gap-5 mb-2">
|
<Card>
|
||||||
<div className="relative w-full">
|
<CardHeader>
|
||||||
<Input
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
placeholder="Cari berdasarkan nama lengkap"
|
<div className="relative w-full sm:max-w-sm">
|
||||||
className="pl-12 w-full max-h-full"
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
/>
|
<Input className="pl-9" placeholder="Cari nama lengkap…" />
|
||||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
|
||||||
<SearchOutlined />
|
|
||||||
</div>
|
</div>
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Semua Status</SelectItem>
|
||||||
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
|
<SelectItem value="confirmed">Confirmed</SelectItem>
|
||||||
|
<SelectItem value="ongoing">On Going</SelectItem>
|
||||||
|
<SelectItem value="completed">Completed</SelectItem>
|
||||||
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
</CardHeader>
|
||||||
<option value="">Semua Status</option>
|
<CardContent>
|
||||||
<option value="pending">Pending</option>
|
{isLoading ? (
|
||||||
<option value="confirmed">Confirmed</option>
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
<option value="ongoing">On Going</option>
|
Memuat data…
|
||||||
<option value="completed">Completed</option>
|
</div>
|
||||||
<option value="cancelled">Cancelled</option>
|
) : (
|
||||||
</Select>
|
<DataTable
|
||||||
</div>
|
data={sessions}
|
||||||
|
columns={columns}
|
||||||
{isLoading ? (
|
table={table}
|
||||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
manualPagination
|
||||||
) : (
|
pageCount={Math.ceil(totalItems / pagination.pageSize)}
|
||||||
<DataTable data={sessions} columns={columns} table={table} />
|
currentPage={pagination.pageIndex + 1}
|
||||||
)}
|
onPageChange={(p) =>
|
||||||
</section>
|
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</BackofficeWrapper>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||||
import { Input, Select, Textarea } from '@imphnen-frontend-service/ui/atoms'
|
import { Input, NativeSelect as Select, Textarea } from '@imphnen-frontend-service/ui/atoms'
|
||||||
import { cn, For } from '@imphnen-frontend-service/utils'
|
import { cn, For } from '@imphnen-frontend-service/utils'
|
||||||
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service'
|
import { useMySessions, TSessionListItem } from '@imphnen-frontend-service/service'
|
||||||
|
|
||||||
|
|||||||
@@ -1,58 +1,74 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms'
|
import * as React from 'react';
|
||||||
import { cn, For } from '@imphnen-frontend-service/utils'
|
import {
|
||||||
import { useState } from 'react'
|
Card,
|
||||||
import { GeneralSettings } from './_components/settings-dimentorin/general'
|
CardContent,
|
||||||
import { UserRolesPermission } from './_components/settings-dimentorin/user-roles-permission'
|
Tabs,
|
||||||
import { NotificationSettings } from './_components/settings-dimentorin/notification'
|
TabsContent,
|
||||||
import { SecuritySettings } from './_components/settings-dimentorin/security'
|
TabsList,
|
||||||
import { PaymentSettings } from './_components/settings-dimentorin/payment'
|
TabsTrigger,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
const TABS = {
|
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||||
general: 'General Settings',
|
import { GeneralSettings } from './_components/settings-dimentorin/general';
|
||||||
userRolePermissions: 'User Roles & Permissions',
|
import { UserRolesPermission } from './_components/settings-dimentorin/user-roles-permission';
|
||||||
notification: 'Notification Settings',
|
import { NotificationSettings } from './_components/settings-dimentorin/notification';
|
||||||
security: 'Security',
|
import { SecuritySettings } from './_components/settings-dimentorin/security';
|
||||||
payment: 'Payment',
|
import { PaymentSettings } from './_components/settings-dimentorin/payment';
|
||||||
} as const
|
|
||||||
type Tabs = typeof TABS[keyof typeof TABS]
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/settings-dimentorin')({
|
export const Route = createFileRoute('/_authenticated/settings-dimentorin')({
|
||||||
component: SettingsDimentorinPage,
|
component: SettingsDimentorinPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function SettingsDimentorinPage(): React.ReactElement {
|
function SettingsDimentorinPage(): React.ReactElement {
|
||||||
const [activeTab, setActiveTab] = useState<Tabs>(TABS.general)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="Dimentorin.dev">
|
<BackofficeWrapper
|
||||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Settings</h1>
|
title="Dimentorin Settings"
|
||||||
|
description="Konfigurasi platform Dimentorin.dev"
|
||||||
<div className="flex items-start gap-x-8">
|
>
|
||||||
<div className="w-64 bg-white p-2.5 shadow space-y-2 rounded-md">
|
<Tabs defaultValue="general" className="gap-6">
|
||||||
<For data={Object.values(TABS)}>
|
<TabsList className="flex-wrap">
|
||||||
{(tab) => (
|
<TabsTrigger value="general">General</TabsTrigger>
|
||||||
<button
|
<TabsTrigger value="roles">Roles & Permissions</TabsTrigger>
|
||||||
key={tab}
|
<TabsTrigger value="notification">Notification</TabsTrigger>
|
||||||
className={cn(
|
<TabsTrigger value="security">Security</TabsTrigger>
|
||||||
'px-4 py-3 w-full text-left font-medium rounded-md text-neutral-400 cursor-pointer select-none hover:bg-primary-100',
|
<TabsTrigger value="payment">Payment</TabsTrigger>
|
||||||
activeTab === tab && 'bg-primary-500 text-white hover:bg-primary-600'
|
</TabsList>
|
||||||
)}
|
<TabsContent value="general">
|
||||||
onClick={() => setActiveTab(tab)}
|
<Card>
|
||||||
>
|
<CardContent className="pt-6">
|
||||||
{tab}
|
<GeneralSettings />
|
||||||
</button>
|
</CardContent>
|
||||||
)}
|
</Card>
|
||||||
</For>
|
</TabsContent>
|
||||||
</div>
|
<TabsContent value="roles">
|
||||||
<div className="bg-white px-8 py-6 shadow space-y-2 rounded-md flex-1">
|
<Card>
|
||||||
{activeTab === TABS.general && <GeneralSettings />}
|
<CardContent className="pt-6">
|
||||||
{activeTab === TABS.userRolePermissions && <UserRolesPermission />}
|
<UserRolesPermission />
|
||||||
{activeTab === TABS.notification && <NotificationSettings />}
|
</CardContent>
|
||||||
{activeTab === TABS.security && <SecuritySettings />}
|
</Card>
|
||||||
{activeTab === TABS.payment && <PaymentSettings />}
|
</TabsContent>
|
||||||
</div>
|
<TabsContent value="notification">
|
||||||
</div>
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<NotificationSettings />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="security">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<SecuritySettings />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="payment">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<PaymentSettings />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</BackofficeWrapper>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import * as React from 'react'
|
import * as React from 'react';
|
||||||
import { FC, Fragment, ReactElement, useState } from 'react'
|
import { Filter as FilterIcon, Search, ClipboardCheck } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
FilterOutlined,
|
Badge,
|
||||||
SearchOutlined,
|
Button,
|
||||||
AuditOutlined,
|
Card,
|
||||||
} from '@ant-design/icons'
|
CardContent,
|
||||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
CardHeader,
|
||||||
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms'
|
Input,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
Filter,
|
||||||
|
BackofficeWrapper,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -15,131 +24,111 @@ import {
|
|||||||
PaginationState,
|
PaginationState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table';
|
||||||
import ModalValidate from './_components/transactions/modal-validate'
|
import ModalValidate from './_components/transactions/modal-validate';
|
||||||
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
type TransactionStatus = 'valid' | 'invalid' | 'unchecked'
|
type TransactionStatus = 'valid' | 'invalid' | 'unchecked';
|
||||||
|
|
||||||
interface Transaction {
|
interface Transaction {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
transactionNumber: string
|
transactionNumber: string;
|
||||||
status: TransactionStatus
|
status: TransactionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockTransactions: Transaction[] = Array.from({ length: 20 }, (_, i) => ({
|
const mockTransactions: Transaction[] = Array.from(
|
||||||
id: i + 1,
|
{ length: 20 },
|
||||||
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
|
(_, i) => ({
|
||||||
transactionNumber: '25D2133Y9AFYBD',
|
id: i + 1,
|
||||||
status: (i % 3 === 0
|
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
|
||||||
? 'invalid'
|
transactionNumber: '25D2133Y9AFYBD',
|
||||||
: i % 5 === 0
|
status: (i % 3 === 0
|
||||||
? 'unchecked'
|
? 'invalid'
|
||||||
: 'valid') as TransactionStatus,
|
: i % 5 === 0
|
||||||
}))
|
? 'unchecked'
|
||||||
|
: 'valid') as TransactionStatus,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/transactions')({
|
export const Route = createFileRoute('/_authenticated/transactions')({
|
||||||
component: TransactionsPage,
|
component: TransactionsPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function TransactionsPage() {
|
function TransactionsPage() {
|
||||||
const [showModalValidate, setShowModalValidate] = useState(false)
|
const [showModalValidate, setShowModalValidate] = React.useState(false);
|
||||||
|
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
const [rowSelection, setRowSelection] =
|
||||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
React.useState<RowSelectionState>({});
|
||||||
const [showFilter, setShowFilter] = useState(false)
|
const [showFilter, setShowFilter] = React.useState(false);
|
||||||
|
|
||||||
const validationOptions = [
|
const validationOptions = [
|
||||||
{ id: 'option1', value: 'unchecked', label: 'Unchecked' },
|
{ id: 'unchecked', value: 'unchecked', label: 'Unchecked' },
|
||||||
{ id: 'option2', value: 'valid', label: 'Valid' },
|
{ id: 'valid', value: 'valid', label: 'Valid' },
|
||||||
{ id: 'option3', value: 'invalid', label: 'Invalid' },
|
{ id: 'invalid', value: 'invalid', label: 'Invalid' },
|
||||||
]
|
];
|
||||||
|
|
||||||
|
const statusVariants: Record<
|
||||||
|
TransactionStatus,
|
||||||
|
'success' | 'destructive' | 'warning'
|
||||||
|
> = {
|
||||||
|
valid: 'success',
|
||||||
|
invalid: 'destructive',
|
||||||
|
unchecked: 'warning',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusText: Record<TransactionStatus, string> = {
|
||||||
|
valid: 'Valid',
|
||||||
|
invalid: 'Invalid',
|
||||||
|
unchecked: 'Unchecked',
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<Transaction>[] = [
|
const columns: ColumnDef<Transaction>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
header: ({ table }) => (
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
<input
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={table.getIsAllRowsSelected()}
|
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onChange={row.getToggleSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'No',
|
|
||||||
accessorKey: 'id',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Nama Lengkap',
|
|
||||||
accessorKey: 'name',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Nomor Transaksi',
|
|
||||||
accessorKey: 'transactionNumber',
|
|
||||||
},
|
},
|
||||||
|
{ header: 'No', accessorKey: 'id' },
|
||||||
|
{ header: 'Nama Lengkap', accessorKey: 'name' },
|
||||||
|
{ header: 'Nomor Transaksi', accessorKey: 'transactionNumber' },
|
||||||
{
|
{
|
||||||
header: 'Order Valid?',
|
header: 'Order Valid?',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const status = row.original.status
|
<Badge variant={statusVariants[row.original.status]}>
|
||||||
const statusColors: Record<TransactionStatus, string> = {
|
{statusText[row.original.status]}
|
||||||
valid: 'bg-success-200 text-success-500',
|
</Badge>
|
||||||
invalid: 'bg-danger-200 text-danger-500',
|
),
|
||||||
unchecked: 'bg-warning-200 text-warning-900',
|
|
||||||
}
|
|
||||||
const statusText: Record<TransactionStatus, string> = {
|
|
||||||
valid: 'Valid',
|
|
||||||
invalid: 'Invalid',
|
|
||||||
unchecked: 'Unchecked',
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
|
||||||
>
|
|
||||||
{statusText[status]}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
cell: ({ row }) => (
|
cell: () => (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setShowModalValidate(true)
|
setShowModalValidate(true);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 w-full"
|
|
||||||
>
|
>
|
||||||
<AuditOutlined className="text-[16px]" /> Update
|
<ClipboardCheck className="size-3.5" />
|
||||||
|
Update
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: mockTransactions,
|
data: mockTransactions,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { pagination, rowSelection },
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -147,64 +136,58 @@ function TransactionsPage() {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(mockTransactions.length / pagination.pageSize),
|
pageCount: Math.ceil(mockTransactions.length / pagination.pageSize),
|
||||||
manualPagination: false,
|
manualPagination: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<BackofficeWrapper
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
title="Validasi Transaksi"
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
description="Verifikasi status transaksi pengguna"
|
||||||
<h1 className="text-p2 font-semibold">Validasi Transaksi</h1>
|
>
|
||||||
</header>
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex justify-between items-center gap-8 mb-2">
|
<div className="relative w-full sm:max-w-sm">
|
||||||
<div className="relative w-full">
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Cari berdasarkan nama lengkap, nomor order Shopee"
|
className="pl-9"
|
||||||
className="pl-12 w-full max-h-full"
|
placeholder="Cari nama atau nomor order…"
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
|
||||||
<SearchOutlined />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
className="flex items-center gap-3"
|
|
||||||
onClick={() => setShowFilter(!showFilter)}
|
|
||||||
>
|
|
||||||
<FilterOutlined />
|
|
||||||
Filters
|
|
||||||
</Button>
|
|
||||||
{showFilter && (
|
|
||||||
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
|
|
||||||
<Filter
|
|
||||||
options={validationOptions}
|
|
||||||
onClose={() => setShowFilter(false)}
|
|
||||||
onFilterChange={(value) => {
|
|
||||||
console.log('Selected filter:', value)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<Popover open={showFilter} onOpenChange={setShowFilter}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="secondary" size="md">
|
||||||
|
<FilterIcon className="size-4" />
|
||||||
|
Filters
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-auto p-0">
|
||||||
|
<Filter
|
||||||
|
options={validationOptions}
|
||||||
|
title="Status"
|
||||||
|
onClose={() => setShowFilter(false)}
|
||||||
|
onFilterChange={(value) => {
|
||||||
|
console.log('Selected filter:', value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
<DataTable data={mockTransactions} columns={columns} table={table} />
|
<DataTable data={mockTransactions} columns={columns} table={table} />
|
||||||
</section>
|
</CardContent>
|
||||||
</main>
|
</Card>
|
||||||
|
|
||||||
<ModalValidate
|
<ModalValidate
|
||||||
isOpen={showModalValidate}
|
isOpen={showModalValidate}
|
||||||
onClose={() => setShowModalValidate(false)}
|
onClose={() => setShowModalValidate(false)}
|
||||||
handleValid={() => {
|
handleValid={() => {
|
||||||
console.log('Action ketika user klik Valid')
|
console.log('Action ketika user klik Valid');
|
||||||
}}
|
}}
|
||||||
handleInvalid={() => {
|
handleInvalid={() => {
|
||||||
console.log('Action ketika user klik Tidak Valid')
|
console.log('Action ketika user klik Tidak Valid');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Fragment>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { DeleteOutlined, SearchOutlined } from '@ant-design/icons'
|
import * as React from 'react';
|
||||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
|
import { Eye, Search, Trash2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
Input,
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from '@imphnen-frontend-service/ui/atoms';
|
||||||
import {
|
import {
|
||||||
BackofficeWrapper,
|
BackofficeWrapper,
|
||||||
DataTable,
|
DataTable,
|
||||||
} from '@imphnen-frontend-service/ui/organisms'
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import { cn, For } from '@imphnen-frontend-service/utils'
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -13,98 +25,85 @@ import {
|
|||||||
PaginationState,
|
PaginationState,
|
||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table';
|
||||||
import { ReactElement, useState } from 'react'
|
import { toast } from 'sonner';
|
||||||
import { toast } from 'sonner'
|
|
||||||
import {
|
import {
|
||||||
useMentorList,
|
useMentorList,
|
||||||
useUserList,
|
useUserList,
|
||||||
useDeleteMentor,
|
useDeleteMentor,
|
||||||
MentorDetailResponseDto,
|
MentorDetailResponseDto,
|
||||||
TUsersListItem,
|
TUsersListItem,
|
||||||
} from '@imphnen-frontend-service/service'
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import {
|
||||||
|
SelectAllCheckbox,
|
||||||
|
RowSelectCheckbox,
|
||||||
|
DeleteConfirmDialog,
|
||||||
|
} from '../../components/list-helpers';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/users-dimentorin')({
|
export const Route = createFileRoute('/_authenticated/users-dimentorin')({
|
||||||
component: UsersDimentorinPage,
|
component: UsersDimentorinPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
function UsersDimentorinPage(): ReactElement {
|
function UsersDimentorinPage() {
|
||||||
const TABS = ['mentor', 'mentee'] as const
|
const navigate = useNavigate();
|
||||||
const navigate = useNavigate()
|
const [activeTab, setActiveTab] = React.useState<'mentor' | 'mentee'>(
|
||||||
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor')
|
'mentor'
|
||||||
const [search, setSearch] = useState('')
|
);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
const [search, setSearch] = React.useState('');
|
||||||
|
const [deletingId, setDeletingId] = React.useState<string | null>(null);
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] =
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
React.useState<RowSelectionState>({});
|
||||||
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 10,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: mentorData, isLoading: mentorLoading } = useMentorList({
|
const { data: mentorData, isLoading: mentorLoading } = useMentorList({
|
||||||
search,
|
search,
|
||||||
page: pagination.pageIndex + 1,
|
page: pagination.pageIndex + 1,
|
||||||
per_page: pagination.pageSize,
|
per_page: pagination.pageSize,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: menteeData, isLoading: menteeLoading } = useUserList({
|
const { data: menteeData, isLoading: menteeLoading } = useUserList({
|
||||||
search,
|
search,
|
||||||
page: pagination.pageIndex + 1,
|
page: pagination.pageIndex + 1,
|
||||||
per_page: pagination.pageSize,
|
per_page: pagination.pageSize,
|
||||||
})
|
});
|
||||||
|
const deleteMentor = useDeleteMentor();
|
||||||
|
|
||||||
const deleteMentor = useDeleteMentor()
|
const mentors: MentorDetailResponseDto[] = mentorData?.data ?? [];
|
||||||
|
const mentees: TUsersListItem[] = menteeData?.data ?? [];
|
||||||
const mentors: MentorDetailResponseDto[] = mentorData?.data ?? []
|
const mentorTotal = mentorData?.meta?.total ?? mentors.length;
|
||||||
const mentees: TUsersListItem[] = menteeData?.data ?? []
|
const menteeTotal = menteeData?.meta?.total ?? mentees.length;
|
||||||
const mentorTotal = mentorData?.meta?.total ?? mentors.length
|
|
||||||
const menteeTotal = menteeData?.meta?.total ?? mentees.length
|
|
||||||
|
|
||||||
const isLoading = activeTab === 'mentor' ? mentorLoading : menteeLoading
|
|
||||||
const totalItems = activeTab === 'mentor' ? mentorTotal : menteeTotal
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteMentor.mutateAsync(id)
|
await deleteMentor.mutateAsync(id);
|
||||||
toast.success('Akun berhasil dihapus')
|
toast.success('Akun berhasil dihapus');
|
||||||
setDeletingId(null)
|
setDeletingId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
toast.error('Gagal menghapus akun')
|
toast.error('Gagal menghapus akun');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const statusVariantMap: Record<
|
||||||
|
string,
|
||||||
|
'success' | 'warning' | 'destructive' | 'secondary'
|
||||||
|
> = {
|
||||||
|
active: 'success',
|
||||||
|
pending: 'warning',
|
||||||
|
inactive: 'destructive',
|
||||||
|
};
|
||||||
|
|
||||||
const mentorColumns: ColumnDef<MentorDetailResponseDto>[] = [
|
const mentorColumns: ColumnDef<MentorDetailResponseDto>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
meta: { cellClassName: cn('w-20') },
|
meta: { cellClassName: cn('w-10') },
|
||||||
header: ({ table }) => (
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
<input
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={table.getIsAllRowsSelected()}
|
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onChange={row.getToggleSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'name',
|
|
||||||
header: 'Name',
|
|
||||||
accessorKey: 'fullname',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'email',
|
|
||||||
header: 'Email',
|
|
||||||
accessorKey: 'email',
|
|
||||||
},
|
},
|
||||||
|
{ id: 'name', header: 'Name', accessorKey: 'fullname' },
|
||||||
|
{ id: 'email', header: 'Email', accessorKey: 'email' },
|
||||||
{
|
{
|
||||||
id: 'rating',
|
id: 'rating',
|
||||||
header: 'Rating',
|
header: 'Rating',
|
||||||
@@ -116,139 +115,86 @@ function UsersDimentorinPage(): ReactElement {
|
|||||||
header: 'Status',
|
header: 'Status',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const status = row.original.status
|
const status = row.original.status ?? '';
|
||||||
const statusColors: Record<string, string> = {
|
|
||||||
active: 'bg-success-200 text-success-500',
|
|
||||||
pending: 'bg-warning-200 text-warning-700',
|
|
||||||
inactive: 'bg-danger-200 text-danger-500',
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className={`py-2 px-4 rounded-md text-center capitalize ${statusColors[status] ?? 'bg-neutral-200 text-neutral-700'}`}>
|
<Badge variant={statusVariantMap[status] ?? 'secondary'} className="capitalize">
|
||||||
{status}
|
{status || 'unknown'}
|
||||||
</div>
|
</Badge>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
meta: { cellClassName: cn('w-72') },
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex gap-[8px]">
|
<div className="flex items-center gap-2">
|
||||||
{deletingId === row.original.id ? (
|
<Button
|
||||||
<>
|
variant="secondary"
|
||||||
<Button
|
size="sm"
|
||||||
variant="danger"
|
onClick={(e) => {
|
||||||
size="sm"
|
e.stopPropagation();
|
||||||
onClick={(e) => {
|
navigate({
|
||||||
e.stopPropagation()
|
to: '/users-dimentorin/$id',
|
||||||
handleDelete(row.original.id)
|
params: { id: row.original.id },
|
||||||
}}
|
});
|
||||||
className="flex items-center gap-2"
|
}}
|
||||||
>
|
>
|
||||||
Konfirmasi
|
<Eye className="size-3.5" />
|
||||||
</Button>
|
Detail
|
||||||
<Button
|
</Button>
|
||||||
variant="bordered"
|
<Button
|
||||||
size="sm"
|
variant="danger"
|
||||||
onClick={(e) => {
|
size="sm"
|
||||||
e.stopPropagation()
|
onClick={(e) => {
|
||||||
setDeletingId(null)
|
e.stopPropagation();
|
||||||
}}
|
setDeletingId(row.original.id);
|
||||||
className="flex items-center gap-2"
|
}}
|
||||||
>
|
>
|
||||||
Batal
|
<Trash2 className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
navigate({ to: '/users-dimentorin/$id', params: { id: row.original.id } })
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 w-max"
|
|
||||||
>
|
|
||||||
<SearchOutlined className="text-[16px]" /> Lihat Detail
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setDeletingId(row.original.id)
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<DeleteOutlined />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const menteeColumns: ColumnDef<TUsersListItem>[] = [
|
const menteeColumns: ColumnDef<TUsersListItem>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
meta: { cellClassName: cn('w-20') },
|
meta: { cellClassName: cn('w-10') },
|
||||||
header: ({ table }) => (
|
header: ({ table }) => <SelectAllCheckbox table={table} />,
|
||||||
<input
|
cell: ({ row }) => <RowSelectCheckbox row={row} />,
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={table.getIsAllRowsSelected()}
|
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="rounded"
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onChange={row.getToggleSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'name',
|
|
||||||
header: 'Name',
|
|
||||||
accessorKey: 'fullname',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'email',
|
|
||||||
header: 'Email',
|
|
||||||
accessorKey: 'email',
|
|
||||||
},
|
},
|
||||||
|
{ id: 'name', header: 'Name', accessorKey: 'fullname' },
|
||||||
|
{ id: 'email', header: 'Email', accessorKey: 'email' },
|
||||||
{
|
{
|
||||||
id: 'status',
|
id: 'status',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
accessorKey: 'is_active',
|
accessorKey: 'is_active',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className={`py-2 px-4 rounded-md text-center ${row.original.is_active ? 'bg-success-200 text-success-500' : 'bg-danger-200 text-danger-500'}`}>
|
<Badge variant={row.original.is_active ? 'success' : 'destructive'}>
|
||||||
{row.original.is_active ? 'Active' : 'Inactive'}
|
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||||
</div>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
meta: { cellClassName: cn('w-72') },
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
navigate({ to: '/users-dimentorin/$id', params: { id: row.original.id } })
|
navigate({
|
||||||
|
to: '/users-dimentorin/$id',
|
||||||
|
params: { id: row.original.id },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 w-max"
|
|
||||||
>
|
>
|
||||||
<SearchOutlined className="text-[16px]" /> Lihat Detail
|
<Eye className="size-3.5" />
|
||||||
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const mentorTable = useReactTable({
|
const mentorTable = useReactTable({
|
||||||
data: mentors,
|
data: mentors,
|
||||||
@@ -261,7 +207,7 @@ function UsersDimentorinPage(): ReactElement {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(mentorTotal / pagination.pageSize),
|
pageCount: Math.ceil(mentorTotal / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
const menteeTable = useReactTable({
|
const menteeTable = useReactTable({
|
||||||
data: mentees,
|
data: mentees,
|
||||||
@@ -274,59 +220,91 @@ function UsersDimentorinPage(): ReactElement {
|
|||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
pageCount: Math.ceil(menteeTotal / pagination.pageSize),
|
pageCount: Math.ceil(menteeTotal / pagination.pageSize),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="Dimentorin.dev">
|
<BackofficeWrapper
|
||||||
<div className="mb-8 flex justify-between items-center">
|
title="Users Dimentorin"
|
||||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">
|
description="Manajemen mentor dan mentee"
|
||||||
User Management
|
>
|
||||||
</h1>
|
<Card>
|
||||||
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
<CardHeader>
|
||||||
<For data={TABS}>
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
{(tab) => (
|
<div className="relative w-full sm:max-w-sm">
|
||||||
<Button
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
key={tab}
|
<Input
|
||||||
variant="text"
|
className="pl-9"
|
||||||
className={cn(
|
placeholder="Cari nama lengkap…"
|
||||||
'px-3 py-2 capitalize',
|
value={search}
|
||||||
activeTab === tab && 'bg-white'
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
)}
|
/>
|
||||||
onClick={() => {
|
|
||||||
setActiveTab(tab)
|
|
||||||
setPagination((p) => ({ ...p, pageIndex: 0 }))
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tab}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
|
||||||
<div className="flex justify-between items-center gap-5 mb-2">
|
|
||||||
<div className="relative w-full">
|
|
||||||
<Input
|
|
||||||
placeholder="Cari berdasarkan nama lengkap"
|
|
||||||
className="pl-12 w-full max-h-full"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
|
||||||
<SearchOutlined />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setActiveTab(v as 'mentor' | 'mentee');
|
||||||
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="mentor" className="capitalize">
|
||||||
|
Mentor ({mentorTotal})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="mentee" className="capitalize">
|
||||||
|
Mentee ({menteeTotal})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="mentor" className="mt-4">
|
||||||
|
{mentorLoading ? (
|
||||||
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Memuat data…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={mentors}
|
||||||
|
columns={mentorColumns}
|
||||||
|
table={mentorTable}
|
||||||
|
manualPagination
|
||||||
|
pageCount={Math.ceil(mentorTotal / pagination.pageSize)}
|
||||||
|
currentPage={pagination.pageIndex + 1}
|
||||||
|
onPageChange={(p) =>
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="mentee" className="mt-4">
|
||||||
|
{menteeLoading ? (
|
||||||
|
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Memuat data…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={mentees}
|
||||||
|
columns={menteeColumns}
|
||||||
|
table={menteeTable}
|
||||||
|
manualPagination
|
||||||
|
pageCount={Math.ceil(menteeTotal / pagination.pageSize)}
|
||||||
|
currentPage={pagination.pageIndex + 1}
|
||||||
|
onPageChange={(p) =>
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: p - 1 }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{isLoading ? (
|
<DeleteConfirmDialog
|
||||||
<div className="text-center py-8 text-neutral-400">Loading...</div>
|
open={!!deletingId}
|
||||||
) : activeTab === 'mentor' ? (
|
onOpenChange={(o) => !o && setDeletingId(null)}
|
||||||
<DataTable data={mentors} columns={mentorColumns} table={mentorTable} />
|
onConfirm={() => deletingId && handleDelete(deletingId)}
|
||||||
) : (
|
title="Hapus akun mentor ini?"
|
||||||
<DataTable data={mentees} columns={menteeColumns} table={menteeTable} />
|
/>
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</BackofficeWrapper>
|
</BackofficeWrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { FC, ReactElement, useState } from 'react'
|
import { FC, ReactElement, useState } from 'react'
|
||||||
import { ControlledInputField, RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms'
|
import { ControlledInputField, RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms'
|
||||||
import { Button, Select } from '@imphnen-frontend-service/ui/atoms'
|
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
||||||
import { ArrowLeftOutlined, ArrowRightOutlined } from '@ant-design/icons'
|
import { ArrowLeftOutlined, ArrowRightOutlined } from '@ant-design/icons'
|
||||||
import { InputField, RegisterMentorStep, SelectField } from '@imphnen-frontend-service/ui/molecules'
|
import { InputField, RegisterMentorStep, SelectField } from '@imphnen-frontend-service/ui/molecules'
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
|
import { Input, NativeSelect as Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
|
||||||
import { cn } from "@imphnen-frontend-service/utils"
|
import { cn } from "@imphnen-frontend-service/utils"
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FC, useState, useEffect, useCallback } from 'react';
|
import { FC, useState, useEffect, useCallback } from 'react';
|
||||||
import { Select } from '@imphnen-frontend-service/ui/atoms';
|
import { NativeSelect as Select } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { SectionWrapper } from '../shared/section-wrapper';
|
import { SectionWrapper } from '../shared/section-wrapper';
|
||||||
import { NotificationType } from '../modals/notification-modal';
|
import { NotificationType } from '../modals/notification-modal';
|
||||||
import { PersonalInfoSection } from '../sections/personal-info-section';
|
import { PersonalInfoSection } from '../sections/personal-info-section';
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "libs/ui/src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@imphnen-frontend-service/ui",
|
||||||
|
"utils": "@imphnen-frontend-service/utils",
|
||||||
|
"ui": "@imphnen-frontend-service/ui/atoms",
|
||||||
|
"lib": "@imphnen-frontend-service/utils",
|
||||||
|
"hooks": "@imphnen-frontend-service/utils"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import { buttonVariants } from '../button/button';
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/50',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg sm:max-w-lg',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn('text-lg font-semibold leading-none', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants({ variant: 'primary' }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: 'secondary' }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './alert-dialog';
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
className={cn(
|
||||||
|
'relative flex size-9 shrink-0 overflow-hidden rounded-full',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn('aspect-square size-full object-cover', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
'flex size-full items-center justify-center rounded-full bg-primary-100 text-primary-700 text-sm font-medium',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './avatar';
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap transition-colors overflow-hidden [&>svg]:size-3 [&>svg]:pointer-events-none',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'border-transparent bg-primary-500 text-white',
|
||||||
|
secondary:
|
||||||
|
'border-transparent bg-neutral-100 text-neutral-700',
|
||||||
|
success:
|
||||||
|
'border-transparent bg-success-100 text-success-700',
|
||||||
|
warning:
|
||||||
|
'border-transparent bg-warning-100 text-warning-800',
|
||||||
|
info:
|
||||||
|
'border-transparent bg-info-100 text-info-700',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-danger-100 text-danger-700',
|
||||||
|
outline:
|
||||||
|
'border-neutral-200 bg-background text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'span'> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : 'span';
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './badge';
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
function Breadcrumb(props: React.ComponentProps<'nav'>) {
|
||||||
|
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn('inline-flex items-center gap-1.5', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'a'> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : 'a';
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn('transition-colors hover:text-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn('font-normal text-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'li'>) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn('[&>svg]:size-3.5', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn('flex size-9 items-center justify-center', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './breadcrumb';
|
||||||
@@ -1,31 +1,31 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
import {
|
|
||||||
FC,
|
|
||||||
ReactElement,
|
|
||||||
ButtonHTMLAttributes,
|
|
||||||
DetailedHTMLProps,
|
|
||||||
} from 'react';
|
|
||||||
import { cn } from '@imphnen-frontend-service/utils';
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
export const buttonVariants = cva(
|
export const buttonVariants = cva(
|
||||||
'inline-flex items-center justify-center font-[600] rounded-md px-[16px] py-[10px] transition-colors duration-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed',
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-semibold transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
primary: 'bg-primary-500 hover:bg-primary-600 text-white shadow-md',
|
primary:
|
||||||
|
'bg-primary-500 text-white shadow-sm hover:bg-primary-600 active:bg-primary-700',
|
||||||
secondary:
|
secondary:
|
||||||
'bg-white hover:text-primary-600 hover:bg-gray-50 text-primary-500 shadow-md border',
|
'border border-neutral-200 bg-white text-primary-500 shadow-sm hover:bg-primary-50 hover:text-primary-600 hover:border-primary-200',
|
||||||
text: 'bg-transparent hover:text-primary-600 hover:bg-gray-50 text-primary-500',
|
text: 'bg-transparent text-primary-500 hover:bg-primary-50 hover:text-primary-600',
|
||||||
bordered:
|
bordered:
|
||||||
'border border-primary-500 hover:border-primary-600 bg-transparent hover:text-primary-600 hover:bg-gray-50 text-primary-500',
|
'border border-primary-500 bg-transparent text-primary-500 hover:bg-primary-50 hover:border-primary-600 hover:text-primary-600',
|
||||||
success: 'bg-success-500 hover:bg-success-600 text-white shadow-md',
|
success:
|
||||||
|
'bg-success-500 text-white shadow-sm hover:bg-success-600 active:bg-success-700',
|
||||||
danger:
|
danger:
|
||||||
'bg-danger-100 hover:bg-danger-200 text-danger-500 shadow-md',
|
'bg-danger-100 text-danger-600 shadow-sm hover:bg-danger-200 hover:text-danger-700',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
sm: 'text-[12px] max-h-[36px]',
|
sm: 'h-8 px-3 text-xs',
|
||||||
md: 'text-[15px] max-h-[40px]',
|
md: 'h-9 px-4 text-sm',
|
||||||
lg: 'text-[19px] max-h-[44px]',
|
lg: 'h-11 px-6 text-base',
|
||||||
icon: 'h-9 w-9 p-0',
|
icon: 'h-9 w-9 p-0',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -36,27 +36,22 @@ export const buttonVariants = cva(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
type TButtonProps = DetailedHTMLProps<
|
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
|
||||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
VariantProps<typeof buttonVariants> & {
|
||||||
HTMLButtonElement
|
asChild?: boolean;
|
||||||
> &
|
};
|
||||||
VariantProps<typeof buttonVariants>;
|
|
||||||
|
|
||||||
export const Button: FC<TButtonProps> = ({
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
variant,
|
({ variant, size, asChild = false, className, ...props }, ref) => {
|
||||||
size,
|
const Comp = asChild ? Slot : 'button';
|
||||||
disabled,
|
return (
|
||||||
className,
|
<Comp
|
||||||
children,
|
ref={ref}
|
||||||
...rest
|
data-slot="button"
|
||||||
}): ReactElement => {
|
className={cn(buttonVariants({ variant, size }), className)}
|
||||||
return (
|
{...props}
|
||||||
<button
|
/>
|
||||||
className={cn(buttonVariants({ variant, size }), className)}
|
);
|
||||||
disabled={disabled}
|
}
|
||||||
{...rest}
|
);
|
||||||
>
|
Button.displayName = 'Button';
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||||
|
import { Check, Minus } from 'lucide-react';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
'peer size-4 shrink-0 rounded-sm border border-neutral-300 bg-background shadow-sm',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'data-[state=checked]:bg-primary-500 data-[state=checked]:text-white data-[state=checked]:border-primary-500',
|
||||||
|
'data-[state=indeterminate]:bg-primary-500 data-[state=indeterminate]:text-white data-[state=indeterminate]:border-primary-500',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="flex items-center justify-center text-current"
|
||||||
|
>
|
||||||
|
{props.checked === 'indeterminate' ? (
|
||||||
|
<Minus className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
)}
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox };
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './checkbox';
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { LuX } from 'react-icons/lu';
|
import { X } from 'lucide-react';
|
||||||
import { cn } from '@imphnen-frontend-service/utils';
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
@@ -70,7 +70,7 @@ function DialogContent({
|
|||||||
data-slot="dialog-close"
|
data-slot="dialog-close"
|
||||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
>
|
>
|
||||||
<LuX />
|
<X />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
|
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal
|
||||||
|
data-slot="dropdown-menu-portal"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: 'default' | 'destructive';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
|
||||||
|
'focus:bg-accent focus:text-accent-foreground',
|
||||||
|
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
'data-[inset]:pl-8',
|
||||||
|
"data-[variant=destructive]:text-danger-600 data-[variant=destructive]:focus:bg-danger-50 data-[variant=destructive]:focus:text-danger-700",
|
||||||
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none',
|
||||||
|
'focus:bg-accent focus:text-accent-foreground',
|
||||||
|
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none',
|
||||||
|
'focus:bg-accent focus:text-accent-foreground',
|
||||||
|
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-neutral-100', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none',
|
||||||
|
'focus:bg-accent focus:text-accent-foreground',
|
||||||
|
'data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
||||||
|
'data-[inset]:pl-8',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './dropdown-menu';
|
||||||
@@ -1,10 +1,26 @@
|
|||||||
|
export * from './alert-dialog';
|
||||||
|
export * from './avatar';
|
||||||
|
export * from './badge';
|
||||||
|
export * from './breadcrumb';
|
||||||
export * from './button';
|
export * from './button';
|
||||||
export * from './card';
|
export * from './card';
|
||||||
|
export * from './checkbox';
|
||||||
export * from './dialog';
|
export * from './dialog';
|
||||||
export * from './drawer';
|
export * from './drawer';
|
||||||
|
export * from './dropdown-menu';
|
||||||
export * from './form';
|
export * from './form';
|
||||||
export * from './input';
|
export * from './input';
|
||||||
export * from './label';
|
export * from './label';
|
||||||
|
export * from './popover';
|
||||||
|
export * from './radio-group';
|
||||||
|
export * from './scroll-area';
|
||||||
export * from './select';
|
export * from './select';
|
||||||
|
export * from './separator';
|
||||||
|
export * from './sheet';
|
||||||
|
export * from './sidebar';
|
||||||
|
export * from './skeleton';
|
||||||
|
export * from './table';
|
||||||
|
export * from './tabs';
|
||||||
export * from './textarea';
|
export * from './textarea';
|
||||||
export * from './toggle';
|
export * from './toggle';
|
||||||
|
export * from './tooltip';
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import * as React from 'react';
|
||||||
DetailedHTMLProps,
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
FC,
|
|
||||||
InputHTMLAttributes,
|
|
||||||
ReactElement,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons'; // Import Ant Design icons
|
|
||||||
import { cn } from '@imphnen-frontend-service/utils';
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
import { Button } from '../button';
|
|
||||||
|
|
||||||
type TInputType =
|
type TInputType =
|
||||||
| 'text'
|
| 'text'
|
||||||
@@ -23,81 +16,80 @@ type TInputSize = 'sm' | 'md' | 'lg';
|
|||||||
type Width = 'standard' | 'custom';
|
type Width = 'standard' | 'custom';
|
||||||
|
|
||||||
type TInputProps = Omit<
|
type TInputProps = Omit<
|
||||||
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
'size' | 'type'
|
'size' | 'type'
|
||||||
> & {
|
> & {
|
||||||
type?: TInputType;
|
type?: TInputType;
|
||||||
size?: TInputSize;
|
size?: TInputSize;
|
||||||
widthform?: Width;
|
widthform?: Width;
|
||||||
disabled?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeClasses: Record<TInputSize, { textSize: string; iconSize: string }> =
|
const sizeClasses: Record<TInputSize, string> = {
|
||||||
{
|
sm: 'h-8 text-xs',
|
||||||
sm: { textSize: 'text-[10px] h-[28px]', iconSize: 'text-[10px]' },
|
md: 'h-9 text-sm',
|
||||||
md: { textSize: 'text-[12px] h-[30px]', iconSize: 'text-[12px]' },
|
lg: 'h-11 text-base',
|
||||||
lg: { textSize: 'text-[15px] h-[34px]', iconSize: 'text-[15px]' },
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const disabledClass = 'opacity-50 hover:border-neutral-200 cursor-not-allowed';
|
export const Input = React.forwardRef<HTMLInputElement, TInputProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
type = 'text',
|
||||||
|
size = 'md',
|
||||||
|
placeholder,
|
||||||
|
widthform = 'standard',
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [showPassword, setShowPassword] = React.useState(false);
|
||||||
|
|
||||||
export const Input: FC<TInputProps> = ({
|
const togglePasswordVisibility = (e: React.MouseEvent) => {
|
||||||
type = 'text',
|
e.preventDefault();
|
||||||
size = 'md',
|
if (!disabled) setShowPassword((prev) => !prev);
|
||||||
placeholder = 'Placeholder',
|
};
|
||||||
widthform = 'standard',
|
|
||||||
disabled,
|
|
||||||
className,
|
|
||||||
...rest
|
|
||||||
}): ReactElement => {
|
|
||||||
const [showPassword, setShowPassword] = useState(false); // State for password visibility
|
|
||||||
|
|
||||||
const togglePasswordVisibility = (e: React.FormEvent) => {
|
return (
|
||||||
e.preventDefault();
|
<div className="relative flex items-center w-full">
|
||||||
if (!disabled) setShowPassword((prev) => !prev);
|
<input
|
||||||
};
|
ref={ref}
|
||||||
|
data-slot="input"
|
||||||
const mergedClassName = cn(
|
type={type === 'password' && showPassword ? 'text' : type}
|
||||||
`px-[12px] py-[8px] text-neutral-800 bg-white placeholder:text-neutral-300 border border-neutral-200 hover:border-blue-300 focus:outline-1 focus:outline-blue-500 rounded-md font-bai-jamjuree w-full ${
|
disabled={disabled}
|
||||||
widthform === 'standard' ? 'min-w-70' : ''
|
placeholder={placeholder}
|
||||||
}`,
|
className={cn(
|
||||||
sizeClasses[size].textSize,
|
'flex w-full rounded-md border border-input bg-background px-3 py-1 font-bai-jamjuree text-foreground shadow-xs transition-colors',
|
||||||
disabled && disabledClass,
|
'placeholder:text-muted-foreground',
|
||||||
className
|
'file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
|
||||||
);
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||||
|
'aria-invalid:border-destructive aria-invalid:ring-destructive/20',
|
||||||
return (
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
<div className="relative flex items-center">
|
sizeClasses[size],
|
||||||
<input
|
type === 'password' && 'pr-9',
|
||||||
className={mergedClassName}
|
widthform === 'standard' && 'min-w-0',
|
||||||
type={type === 'password' && showPassword ? 'text' : type}
|
className
|
||||||
disabled={disabled}
|
)}
|
||||||
placeholder={placeholder}
|
{...rest}
|
||||||
{...rest}
|
/>
|
||||||
/>
|
{type === 'password' && (
|
||||||
{type === 'password' && (
|
<button
|
||||||
<div className="absolute end-0 px-3 h-full flex items-center">
|
|
||||||
<Button
|
|
||||||
type="button"
|
type="button"
|
||||||
variant="text"
|
tabIndex={-1}
|
||||||
size={size}
|
|
||||||
onClick={togglePasswordVisibility}
|
onClick={togglePasswordVisibility}
|
||||||
className={cn(
|
disabled={disabled}
|
||||||
'relative aspect-square -me-2 p-1.5',
|
className="absolute right-2 inline-flex h-7 w-7 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
sizeClasses[size].iconSize,
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||||
disabled && 'cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{showPassword ? (
|
{showPassword ? (
|
||||||
<EyeInvisibleOutlined
|
<EyeOff className="size-4" />
|
||||||
style={{ color: 'var(--color-neutral-500)' }}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<EyeOutlined style={{ color: 'var(--color-neutral-500)' }} />
|
<Eye className="size-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
};
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './popover';
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = 'center',
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './radio-group';
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
|
||||||
|
import { Circle } from 'lucide-react';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn('grid gap-3', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
'aspect-square size-4 shrink-0 rounded-full border border-neutral-300 text-primary-500 shadow-sm',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'data-[state=checked]:border-primary-500',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Circle className="size-2 fill-primary-500 text-primary-500" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem };
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './scroll-area';
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn('relative', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="size-full rounded-[inherit] outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = 'vertical',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'flex touch-none select-none p-px transition-colors',
|
||||||
|
orientation === 'vertical' &&
|
||||||
|
'h-full w-2.5 border-l border-l-transparent',
|
||||||
|
orientation === 'horizontal' &&
|
||||||
|
'h-2.5 w-full flex-col border-t border-t-transparent',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="relative flex-1 rounded-full bg-neutral-300"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
@@ -1,50 +1,248 @@
|
|||||||
import {
|
'use client';
|
||||||
FC,
|
|
||||||
ReactElement,
|
import * as React from 'react';
|
||||||
SelectHTMLAttributes,
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
} from 'react';
|
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { cn } from '@imphnen-frontend-service/utils';
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
type TSelectSize = 'sm' | 'md' | 'lg';
|
/* ------------------------------------------------------------------ */
|
||||||
type Width = 'standard' | 'custom';
|
/* Radix-backed shadcn Select */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
type TSelectProps = Omit<
|
function Select({
|
||||||
SelectHTMLAttributes<HTMLSelectElement>,
|
...props
|
||||||
'size'
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
> & {
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
size?: TSelectSize;
|
}
|
||||||
widthform?: Width;
|
|
||||||
disabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sizeClasses: Record<TSelectSize, string> = {
|
function SelectGroup({
|
||||||
sm: 'text-[10px] max-h-[34px]',
|
...props
|
||||||
md: 'text-[12px] max-h-[36px]',
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
lg: 'text-[15px] max-h-[38px]',
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
};
|
}
|
||||||
|
|
||||||
const disabledClass =
|
function SelectValue({
|
||||||
'opacity-50 hover:border-neutral-200 cursor-not-allowed';
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
export const Select: FC<TSelectProps> = ({
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
widthform = 'standard',
|
children,
|
||||||
disabled,
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}) {
|
||||||
|
const sizeCls =
|
||||||
|
size === 'sm' ? 'h-8 text-xs' : size === 'lg' ? 'h-11 text-base' : 'h-9 text-sm';
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-1 font-bai-jamjuree text-foreground shadow-xs transition-colors outline-none",
|
||||||
|
'data-[placeholder]:text-muted-foreground',
|
||||||
|
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||||
|
'aria-invalid:border-destructive aria-invalid:ring-destructive/20',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
'*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2',
|
||||||
|
sizeCls,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
...rest
|
position = 'popper',
|
||||||
}): ReactElement => {
|
...props
|
||||||
const mergedClassName = cn(
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
`appearance-none px-[12px] py-[8px] text-neutral-800 bg-white placeholder:text-neutral-300 border border-neutral-200 hover:border-blue-300 focus:outline-1 focus:outline-blue-500 rounded-md`,
|
|
||||||
sizeClasses[size],
|
|
||||||
widthform === 'standard' && 'min-w-70',
|
|
||||||
disabled && disabledClass,
|
|
||||||
className
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select className={mergedClassName} disabled={disabled} {...rest}>
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn('px-2 py-1.5 text-xs text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none",
|
||||||
|
'focus:bg-accent focus:text-accent-foreground',
|
||||||
|
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default items-center justify-center py-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default items-center justify-center py-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* NativeSelect — backward-compat wrapper over <select> for callers */
|
||||||
|
/* that still pass <option> children (SelectField, legacy pages). */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type TNativeSelectSize = 'sm' | 'md' | 'lg';
|
||||||
|
type Width = 'standard' | 'custom';
|
||||||
|
|
||||||
|
type TNativeSelectProps = Omit<
|
||||||
|
React.SelectHTMLAttributes<HTMLSelectElement>,
|
||||||
|
'size'
|
||||||
|
> & {
|
||||||
|
size?: TNativeSelectSize;
|
||||||
|
widthform?: Width;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nativeSizeClasses: Record<TNativeSelectSize, string> = {
|
||||||
|
sm: 'h-8 text-xs',
|
||||||
|
md: 'h-9 text-sm',
|
||||||
|
lg: 'h-11 text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NativeSelect = React.forwardRef<
|
||||||
|
HTMLSelectElement,
|
||||||
|
TNativeSelectProps
|
||||||
|
>(({ size = 'md', widthform = 'standard', disabled, className, children, ...rest }, ref) => {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
'appearance-none rounded-md border border-input bg-background px-3 py-1 font-bai-jamjuree text-foreground shadow-xs transition-colors',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
nativeSizeClasses[size],
|
||||||
|
widthform === 'standard' && 'min-w-0 w-full',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
NativeSelect.displayName = 'NativeSelect';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './separator';
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'bg-neutral-200 shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './sheet';
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/50',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = 'right',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
'fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||||
|
side === 'right' &&
|
||||||
|
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||||
|
side === 'left' &&
|
||||||
|
'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||||
|
side === 'top' &&
|
||||||
|
'inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||||
|
side === 'bottom' &&
|
||||||
|
'inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||||
|
<X className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn('flex flex-col gap-1.5 p-4', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn('text-foreground font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './sidebar';
|
||||||
|
export * from './use-mobile';
|
||||||
@@ -0,0 +1,681 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { PanelLeft } from 'lucide-react';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import { Button } from '../button';
|
||||||
|
import { Separator } from '../separator';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '../sheet';
|
||||||
|
import { Skeleton } from '../skeleton';
|
||||||
|
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '../tooltip';
|
||||||
|
import { useIsMobile } from './use-mobile';
|
||||||
|
|
||||||
|
const SIDEBAR_COOKIE_NAME = 'sidebar_state';
|
||||||
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||||
|
const SIDEBAR_WIDTH = '16rem';
|
||||||
|
const SIDEBAR_WIDTH_MOBILE = '18rem';
|
||||||
|
const SIDEBAR_WIDTH_ICON = '3rem';
|
||||||
|
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
|
||||||
|
|
||||||
|
type SidebarContextProps = {
|
||||||
|
state: 'expanded' | 'collapsed';
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
openMobile: boolean;
|
||||||
|
setOpenMobile: (open: boolean) => void;
|
||||||
|
isMobile: boolean;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||||
|
|
||||||
|
function useSidebar() {
|
||||||
|
const context = React.useContext(SidebarContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSidebar must be used within a SidebarProvider.');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarProvider({
|
||||||
|
defaultOpen = true,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange: setOpenProp,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [openMobile, setOpenMobile] = React.useState(false);
|
||||||
|
|
||||||
|
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||||
|
const open = openProp ?? _open;
|
||||||
|
const setOpen = React.useCallback(
|
||||||
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
|
const openState = typeof value === 'function' ? value(open) : value;
|
||||||
|
if (setOpenProp) {
|
||||||
|
setOpenProp(openState);
|
||||||
|
} else {
|
||||||
|
_setOpen(openState);
|
||||||
|
}
|
||||||
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||||
|
},
|
||||||
|
[setOpenProp, open]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleSidebar = React.useCallback(() => {
|
||||||
|
return isMobile
|
||||||
|
? setOpenMobile((o) => !o)
|
||||||
|
: setOpen((o) => !o);
|
||||||
|
}, [isMobile, setOpen, setOpenMobile]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||||
|
(event.metaKey || event.ctrlKey)
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleSidebar();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [toggleSidebar]);
|
||||||
|
|
||||||
|
const state: 'expanded' | 'collapsed' = open ? 'expanded' : 'collapsed';
|
||||||
|
|
||||||
|
const contextValue = React.useMemo<SidebarContextProps>(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
isMobile,
|
||||||
|
openMobile,
|
||||||
|
setOpenMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
}),
|
||||||
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={contextValue}>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-wrapper"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--sidebar-width': SIDEBAR_WIDTH,
|
||||||
|
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||||
|
...style,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
side = 'left',
|
||||||
|
variant = 'sidebar',
|
||||||
|
collapsible = 'offcanvas',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
side?: 'left' | 'right';
|
||||||
|
variant?: 'sidebar' | 'floating' | 'inset';
|
||||||
|
collapsible?: 'offcanvas' | 'icon' | 'none';
|
||||||
|
}) {
|
||||||
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||||
|
|
||||||
|
if (collapsible === 'none') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar"
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||||
|
<SheetContent
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
side={side}
|
||||||
|
>
|
||||||
|
<SheetHeader className="sr-only">
|
||||||
|
<SheetTitle>Sidebar</SheetTitle>
|
||||||
|
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group peer hidden text-sidebar-foreground md:block"
|
||||||
|
data-state={state}
|
||||||
|
data-collapsible={state === 'collapsed' ? collapsible : ''}
|
||||||
|
data-variant={variant}
|
||||||
|
data-side={side}
|
||||||
|
data-slot="sidebar"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-gap"
|
||||||
|
className={cn(
|
||||||
|
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
|
||||||
|
'group-data-[collapsible=offcanvas]:w-0',
|
||||||
|
'group-data-[side=right]:rotate-180',
|
||||||
|
variant === 'floating' || variant === 'inset'
|
||||||
|
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
|
||||||
|
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-container"
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
|
||||||
|
side === 'left'
|
||||||
|
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||||
|
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||||
|
variant === 'floating' || variant === 'inset'
|
||||||
|
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||||
|
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar-inner"
|
||||||
|
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarTrigger({
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-sidebar="trigger"
|
||||||
|
data-slot="sidebar-trigger"
|
||||||
|
variant="text"
|
||||||
|
size="icon"
|
||||||
|
className={cn('size-7', className)}
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick?.(event);
|
||||||
|
toggleSidebar();
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<PanelLeft />
|
||||||
|
<span className="sr-only">Toggle Sidebar</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
|
||||||
|
const { toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
data-sidebar="rail"
|
||||||
|
data-slot="sidebar-rail"
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
title="Toggle Sidebar"
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear sm:flex',
|
||||||
|
'after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border',
|
||||||
|
'group-data-[side=left]:-right-4 group-data-[side=right]:left-0',
|
||||||
|
'[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize',
|
||||||
|
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
|
||||||
|
'hover:group-data-[collapsible=offcanvas]:bg-sidebar',
|
||||||
|
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
|
||||||
|
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
|
||||||
|
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
data-slot="sidebar-inset"
|
||||||
|
className={cn(
|
||||||
|
'bg-background relative flex w-full flex-1 flex-col',
|
||||||
|
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-sidebar="header"
|
||||||
|
data-slot="sidebar-header"
|
||||||
|
className={cn('flex flex-col gap-2 p-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-sidebar="footer"
|
||||||
|
data-slot="sidebar-footer"
|
||||||
|
className={cn('flex flex-col gap-2 p-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-sidebar="separator"
|
||||||
|
data-slot="sidebar-separator"
|
||||||
|
className={cn('bg-sidebar-border mx-2 w-auto', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-sidebar="content"
|
||||||
|
data-slot="sidebar-content"
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-sidebar="group"
|
||||||
|
data-slot="sidebar-group"
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full min-w-0 flex-col p-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupLabel({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : 'div';
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-sidebar="group-label"
|
||||||
|
data-slot="sidebar-group-label"
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2',
|
||||||
|
"[&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupAction({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-sidebar="group-action"
|
||||||
|
data-slot="sidebar-group-action"
|
||||||
|
className={cn(
|
||||||
|
'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
'after:absolute after:-inset-2 md:after:hidden',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-sidebar="group-content"
|
||||||
|
data-slot="sidebar-group-content"
|
||||||
|
className={cn('w-full text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-sidebar="menu"
|
||||||
|
data-slot="sidebar-menu"
|
||||||
|
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'li'>) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-sidebar="menu-item"
|
||||||
|
data-slot="sidebar-menu-item"
|
||||||
|
className={cn('group/menu-item relative', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarMenuButtonVariants = cva(
|
||||||
|
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-ring transition-[width,height,padding] focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||||
|
outline:
|
||||||
|
'bg-background shadow-[0_0_0_1px_var(--color-sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--color-sidebar-accent)]',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-8 text-sm',
|
||||||
|
sm: 'h-7 text-xs',
|
||||||
|
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function SidebarMenuButton({
|
||||||
|
asChild = false,
|
||||||
|
isActive = false,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
tooltip,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'button'> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||||
|
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
const { isMobile, state } = useSidebar();
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<Comp
|
||||||
|
data-sidebar="menu-button"
|
||||||
|
data-slot="sidebar-menu-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tooltip) {
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof tooltip === 'string') {
|
||||||
|
tooltip = { children: tooltip };
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
hidden={state !== 'collapsed' || isMobile}
|
||||||
|
{...tooltip}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuAction({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
showOnHover = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'button'> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
showOnHover?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-sidebar="menu-action"
|
||||||
|
data-slot="sidebar-menu-action"
|
||||||
|
className={cn(
|
||||||
|
'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
'after:absolute after:-inset-2 md:after:hidden',
|
||||||
|
'peer-data-[size=sm]/menu-button:top-1',
|
||||||
|
'peer-data-[size=default]/menu-button:top-1.5',
|
||||||
|
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
showOnHover &&
|
||||||
|
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuBadge({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
data-slot="sidebar-menu-badge"
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground',
|
||||||
|
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
|
||||||
|
'peer-data-[size=sm]/menu-button:top-1',
|
||||||
|
'peer-data-[size=default]/menu-button:top-1.5',
|
||||||
|
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSkeleton({
|
||||||
|
className,
|
||||||
|
showIcon = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & { showIcon?: boolean }) {
|
||||||
|
const width = React.useMemo(() => `${Math.floor(Math.random() * 40) + 50}%`, []);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-sidebar="menu-skeleton"
|
||||||
|
data-slot="sidebar-menu-skeleton"
|
||||||
|
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
|
||||||
|
<Skeleton
|
||||||
|
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||||
|
data-sidebar="menu-skeleton-text"
|
||||||
|
style={{ '--skeleton-width': width } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-sidebar="menu-sub"
|
||||||
|
data-slot="sidebar-menu-sub"
|
||||||
|
className={cn(
|
||||||
|
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'li'>) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-sidebar="menu-sub-item"
|
||||||
|
data-slot="sidebar-menu-sub-item"
|
||||||
|
className={cn('group/menu-sub-item relative', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubButton({
|
||||||
|
asChild = false,
|
||||||
|
size = 'md',
|
||||||
|
isActive,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'a'> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
isActive?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : 'a';
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-sidebar="menu-sub-button"
|
||||||
|
data-slot="sidebar-menu-sub-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
|
||||||
|
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
|
||||||
|
size === 'sm' && 'text-xs',
|
||||||
|
size === 'md' && 'text-sm',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupAction,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuBadge,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSkeleton,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||||
|
const onChange = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
|
};
|
||||||
|
mql.addEventListener('change', onChange);
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
|
return () => mql.removeEventListener('change', onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return !!isMobile;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './skeleton';
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn('animate-pulse rounded-md bg-neutral-100', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './table';
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn('w-full caption-bottom text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn('[&_tr]:border-b', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn('[&_tr:last-child]:border-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
'border-t bg-neutral-50 font-medium [&>tr]:last:border-b-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
'border-b transition-colors hover:bg-neutral-50/60 data-[state=selected]:bg-primary-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
'h-10 px-3 text-left align-middle text-xs font-semibold text-muted-foreground whitespace-nowrap [&:has([role=checkbox])]:pr-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
'p-3 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'caption'>) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './tabs';
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
className={cn('flex flex-col gap-4', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-10 w-fit items-center justify-center rounded-md bg-neutral-100 p-1 text-muted-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center gap-1.5 rounded-sm px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-all',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
'data-[state=active]:bg-white data-[state=active]:text-primary-600 data-[state=active]:shadow-sm',
|
||||||
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn('flex-1 outline-none', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
@@ -1,18 +1,12 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
import { cn } from '@imphnen-frontend-service/utils';
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
import {
|
|
||||||
DetailedHTMLProps,
|
|
||||||
FC,
|
|
||||||
ReactElement,
|
|
||||||
TextareaHTMLAttributes,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
type TTextareaSize = 'sm' | 'md' | 'lg';
|
type TTextareaSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
type TTextareaProps = Omit<
|
type TTextareaProps = Omit<
|
||||||
DetailedHTMLProps<
|
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||||
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
|
||||||
HTMLTextAreaElement
|
|
||||||
>,
|
|
||||||
'size'
|
'size'
|
||||||
> & {
|
> & {
|
||||||
size?: TTextareaSize;
|
size?: TTextareaSize;
|
||||||
@@ -20,40 +14,40 @@ type TTextareaProps = Omit<
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sizeClasses: Record<TTextareaSize, string> = {
|
const sizeClasses: Record<TTextareaSize, string> = {
|
||||||
sm: 'text-[10px]',
|
sm: 'text-xs min-h-[60px]',
|
||||||
md: 'text-[12px]',
|
md: 'text-sm min-h-[72px]',
|
||||||
lg: 'text-[15px]',
|
lg: 'text-base min-h-[96px]',
|
||||||
};
|
};
|
||||||
|
|
||||||
const disabledClass = 'opacity-50 hover:border-neutral-200 cursor-not-allowed';
|
export const Textarea = React.forwardRef<HTMLTextAreaElement, TTextareaProps>(
|
||||||
const errorClass =
|
({ size = 'md', placeholder, disabled, error, className, ...rest }, ref) => {
|
||||||
'border-danger-500 hover:border-danger-500 focus:outline-danger-500';
|
return (
|
||||||
|
<div className="w-full">
|
||||||
export const Textarea: FC<TTextareaProps> = ({
|
<textarea
|
||||||
size = 'md',
|
ref={ref}
|
||||||
placeholder = 'Placeholder',
|
data-slot="textarea"
|
||||||
disabled,
|
disabled={disabled}
|
||||||
error,
|
placeholder={placeholder}
|
||||||
className,
|
aria-invalid={!!error}
|
||||||
...rest
|
className={cn(
|
||||||
}): ReactElement => {
|
'flex w-full rounded-md border border-input bg-background px-3 py-2 font-bai-jamjuree text-foreground shadow-xs transition-colors',
|
||||||
const mergedClassName = cn(
|
'placeholder:text-muted-foreground',
|
||||||
'rounded-md border border-neutral-200 hover:border-blue-300 focus:outline-1 focus:outline-blue-500 px-[12px] py-[8px] bg-white text-neutral-800 placeholder:text-neutral-300 invalid:border-danger-500 invalid:text-danger-500',
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||||
sizeClasses[size],
|
'aria-invalid:border-destructive aria-invalid:ring-destructive/20',
|
||||||
disabled && disabledClass,
|
'disabled:cursor-not-allowed disabled:opacity-50 disabled:resize-none',
|
||||||
error && errorClass,
|
'resize-y',
|
||||||
className
|
sizeClasses[size],
|
||||||
);
|
className
|
||||||
return (
|
)}
|
||||||
<>
|
{...rest}
|
||||||
<textarea
|
/>
|
||||||
className={mergedClassName}
|
{error && (
|
||||||
placeholder={placeholder}
|
<p className="mt-1 text-xs text-destructive" data-slot="textarea-error">
|
||||||
disabled={disabled}
|
{error}
|
||||||
style={{ resize: disabled ? 'none' : 'both' }}
|
</p>
|
||||||
{...rest}
|
)}
|
||||||
></textarea>
|
</div>
|
||||||
{error && <p className="text-danger-500 text-xs">{error}</p>}
|
);
|
||||||
</>
|
}
|
||||||
);
|
);
|
||||||
};
|
Textarea.displayName = 'Textarea';
|
||||||
|
|||||||
@@ -1,27 +1,87 @@
|
|||||||
import { cn } from "@imphnen-frontend-service/utils";
|
'use client';
|
||||||
import { DetailedHTMLProps, FC, HTMLAttributes } from "react";
|
|
||||||
|
|
||||||
export type ToggleInputProps = DetailedHTMLProps<
|
import * as React from 'react';
|
||||||
HTMLAttributes<HTMLInputElement>,
|
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||||
HTMLInputElement
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
/* Radix-backed Switch primitive */
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
data-slot="switch"
|
||||||
|
className={cn(
|
||||||
|
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'data-[state=checked]:bg-primary-500 data-[state=unchecked]:bg-neutral-200',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform',
|
||||||
|
'data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
));
|
||||||
|
Switch.displayName = 'Switch';
|
||||||
|
|
||||||
|
/* Backward-compat ToggleInput: label + switch row */
|
||||||
|
export type ToggleInputProps = Omit<
|
||||||
|
React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
|
'type' | 'onChange' | 'checked' | 'defaultChecked'
|
||||||
> & {
|
> & {
|
||||||
label?: string
|
label?: string;
|
||||||
labelClassName?: string
|
labelClassName?: string;
|
||||||
}
|
checked?: boolean;
|
||||||
|
defaultChecked?: boolean;
|
||||||
|
onChange?: (checked: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export const ToggleInput: FC<ToggleInputProps> = ({ label, className, labelClassName, ...rest }) => {
|
export const ToggleInput: React.FC<ToggleInputProps> = ({
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
labelClassName,
|
||||||
|
checked,
|
||||||
|
defaultChecked,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
id,
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
|
const reactId = React.useId();
|
||||||
|
const fieldId = id ?? reactId;
|
||||||
return (
|
return (
|
||||||
<label className={cn("flex items-center gap-5 cursor-pointer mb-8", className)}>
|
<div
|
||||||
<span className={cn("text-p3 text-gray-800 font-medium", labelClassName)}>{label}</span>
|
className={cn('flex items-center justify-between gap-4 py-2', className)}
|
||||||
<input type="checkbox" className="sr-only peer" {...rest} />
|
>
|
||||||
<div
|
{label && (
|
||||||
className={cn(
|
<label
|
||||||
"w-14 h-7 bg-gray-300 rounded-3xl relative transition-colors",
|
htmlFor={fieldId}
|
||||||
"peer-checked:bg-blue-600 peer-focus:outline peer-focus:outline-blue-500 peer-checked:[&>div]:translate-x-6.5",
|
className={cn(
|
||||||
)}
|
'text-sm font-medium text-foreground cursor-pointer select-none',
|
||||||
>
|
disabled && 'opacity-50 cursor-not-allowed',
|
||||||
<div className="absolute left-0.5 top-0.5 h-6 w-6 bg-white rounded-full shadow transition-transform peer-checked:translate-x-6.5" />
|
labelClassName
|
||||||
</div>
|
)}
|
||||||
</label>
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<Switch
|
||||||
|
id={fieldId}
|
||||||
|
checked={checked}
|
||||||
|
defaultChecked={defaultChecked}
|
||||||
|
onCheckedChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
{...(rest as React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './tooltip';
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 w-fit rounded-md bg-neutral-900 px-3 py-1.5 text-xs text-white text-balance shadow-md',
|
||||||
|
'animate-in fade-in-0 zoom-in-95',
|
||||||
|
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||||
|
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-neutral-900 fill-neutral-900 z-50 size-2 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap');
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
@import 'tw-animate-css';
|
||||||
@source "../../../libs/ui/**/*.{ts,tsx}";
|
@source "../../../libs/ui/**/*.{ts,tsx}";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
@@ -96,6 +97,45 @@
|
|||||||
/* ~10px */
|
/* ~10px */
|
||||||
--text-label3: 0.677rem;
|
--text-label3: 0.677rem;
|
||||||
/* ~8px */
|
/* ~8px */
|
||||||
|
|
||||||
|
/* shadcn/ui semantic tokens — aliased to existing palette (keep colors consistent) */
|
||||||
|
--color-background: #ffffff;
|
||||||
|
--color-foreground: #3d3d3d;
|
||||||
|
--color-card: #ffffff;
|
||||||
|
--color-card-foreground: #3d3d3d;
|
||||||
|
--color-popover: #ffffff;
|
||||||
|
--color-popover-foreground: #3d3d3d;
|
||||||
|
--color-primary: #23a1eb;
|
||||||
|
--color-primary-foreground: #ffffff;
|
||||||
|
--color-secondary: #e7e7e7;
|
||||||
|
--color-secondary-foreground: #3d3d3d;
|
||||||
|
--color-muted: #f6f6f6;
|
||||||
|
--color-muted-foreground: #6d6d6d;
|
||||||
|
--color-accent: #e1f0fd;
|
||||||
|
--color-accent-foreground: #085f9c;
|
||||||
|
--color-destructive: #ff5242;
|
||||||
|
--color-destructive-foreground: #ffffff;
|
||||||
|
--color-border: #d1d1d1;
|
||||||
|
--color-input: #d1d1d1;
|
||||||
|
--color-ring: #3eb0f2;
|
||||||
|
|
||||||
|
--color-sidebar: #ffffff;
|
||||||
|
--color-sidebar-foreground: #4f4f4f;
|
||||||
|
--color-sidebar-primary: #23a1eb;
|
||||||
|
--color-sidebar-primary-foreground: #ffffff;
|
||||||
|
--color-sidebar-accent: #f0f8ff;
|
||||||
|
--color-sidebar-accent-foreground: #085f9c;
|
||||||
|
--color-sidebar-border: #e7e7e7;
|
||||||
|
--color-sidebar-ring: #3eb0f2;
|
||||||
|
|
||||||
|
--color-chart-1: #23a1eb;
|
||||||
|
--color-chart-2: #35ba43;
|
||||||
|
--color-chart-3: #04acf3;
|
||||||
|
--color-chart-4: #ffed27;
|
||||||
|
--color-chart-5: #ff5242;
|
||||||
|
|
||||||
|
--radius: 8px;
|
||||||
|
--font-sans: 'Bai Jamjuree', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@@ -1,95 +1,77 @@
|
|||||||
import {
|
import * as React from 'react';
|
||||||
DetailedHTMLProps,
|
import { Input } from '../../atoms/input';
|
||||||
FC,
|
import { Label } from '../../atoms/label';
|
||||||
InputHTMLAttributes,
|
|
||||||
ReactElement,
|
|
||||||
} from 'react';
|
|
||||||
import { Input } from '../../atoms';
|
|
||||||
import { cn } from '@imphnen-frontend-service/utils';
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
export type TInputType = 'text' | 'email' | 'number' | 'password' | 'file';
|
export type TInputType =
|
||||||
|
| 'text'
|
||||||
|
| 'email'
|
||||||
|
| 'number'
|
||||||
|
| 'password'
|
||||||
|
| 'file'
|
||||||
|
| 'date'
|
||||||
|
| 'time';
|
||||||
export type TInputSize = 'sm' | 'md' | 'lg';
|
export type TInputSize = 'sm' | 'md' | 'lg';
|
||||||
export type TInputFieldProps = Omit<
|
export type TInputFieldProps = Omit<
|
||||||
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
'size' | 'type'
|
'size' | 'type'
|
||||||
> & {
|
> & {
|
||||||
label: string;
|
label: string;
|
||||||
type?: TInputType;
|
type?: TInputType;
|
||||||
size?: TInputSize;
|
size?: TInputSize;
|
||||||
error?: string;
|
error?: string;
|
||||||
disabled?: boolean;
|
|
||||||
helperText?: string;
|
helperText?: string;
|
||||||
htmlFor?: string;
|
htmlFor?: string;
|
||||||
isRequired?: boolean;
|
isRequired?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeClasses: Record<TInputSize, { label: string; helperText: string }> = {
|
export const InputField = React.forwardRef<HTMLInputElement, TInputFieldProps>(
|
||||||
lg: {
|
(
|
||||||
label: 'text-label1 font-medium',
|
{
|
||||||
helperText: 'text-label3 font-normal',
|
label,
|
||||||
},
|
placeholder,
|
||||||
md: {
|
type = 'text',
|
||||||
label: 'text-label2 font-medium',
|
size = 'md',
|
||||||
helperText: 'text-label2 font-normal',
|
error,
|
||||||
},
|
helperText,
|
||||||
sm: {
|
htmlFor,
|
||||||
label: 'text-label3 font-medium',
|
className,
|
||||||
helperText: 'text-label2 font-normal',
|
disabled,
|
||||||
},
|
isRequired = false,
|
||||||
};
|
id,
|
||||||
|
...rest
|
||||||
export const InputField: FC<TInputFieldProps> = ({
|
},
|
||||||
label,
|
ref
|
||||||
placeholder,
|
) => {
|
||||||
type = 'text',
|
const autoId = React.useId();
|
||||||
size = 'md',
|
const fieldId = htmlFor ?? id ?? autoId;
|
||||||
error,
|
return (
|
||||||
helperText,
|
<div className="flex flex-col gap-2">
|
||||||
htmlFor,
|
<Label htmlFor={fieldId} className="text-sm font-medium text-foreground">
|
||||||
className,
|
{label}
|
||||||
disabled,
|
{isRequired && <span className="text-destructive">*</span>}
|
||||||
isRequired = false,
|
</Label>
|
||||||
...rest
|
<Input
|
||||||
}): ReactElement => {
|
ref={ref}
|
||||||
return (
|
id={fieldId}
|
||||||
<div className="flex gap-[8px] flex-col">
|
placeholder={placeholder}
|
||||||
<label
|
type={type}
|
||||||
htmlFor={htmlFor}
|
size={size}
|
||||||
className={cn(
|
disabled={disabled}
|
||||||
'items-start justify-item-start text-start text-neutral-800!',
|
aria-invalid={!!error}
|
||||||
sizeClasses[size].label
|
className={cn(
|
||||||
)}
|
error && 'border-destructive focus-visible:ring-destructive/20',
|
||||||
>
|
className
|
||||||
{label} {isRequired ? <span className="text-red-500">*</span> : null}
|
)}
|
||||||
</label>
|
{...rest}
|
||||||
<Input
|
/>
|
||||||
{...(htmlFor && { id: htmlFor })}
|
{error ? (
|
||||||
placeholder={placeholder}
|
<p className="text-xs text-destructive">{error}</p>
|
||||||
type={type}
|
) : helperText ? (
|
||||||
size={size}
|
<p className="text-xs text-muted-foreground">{helperText}</p>
|
||||||
disabled={disabled}
|
) : null}
|
||||||
className={cn(
|
</div>
|
||||||
error &&
|
);
|
||||||
'border-danger-500 hover:border-danger-500 focus:outline-danger-500',
|
}
|
||||||
className,
|
);
|
||||||
disabled && 'opacity-50 cursor-not-allowed'
|
InputField.displayName = 'InputField';
|
||||||
)}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
{error ? (
|
|
||||||
<p className="text-danger-500 text-label2 text-left">{error}</p>
|
|
||||||
) : (
|
|
||||||
helperText && (
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'text-label2 text-left',
|
|
||||||
sizeClasses[size].helperText
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{helperText}
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { CloseOutlined } from '@ant-design/icons';
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
import { cn } from '@imphnen-frontend-service/utils';
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
import React, { useEffect, useMemo, useCallback } from 'react';
|
import {
|
||||||
import { createPortal } from 'react-dom';
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '../../atoms/dialog';
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -16,170 +24,83 @@ interface ModalProps {
|
|||||||
'aria-describedby'?: string;
|
'aria-describedby'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Modal = ({
|
/**
|
||||||
|
* Modal — backward-compat wrapper around the shadcn Dialog primitives.
|
||||||
|
* Existing callers use `<Modal isOpen onClose>` + `Modal.Header/Content/Footer/Title/Description`.
|
||||||
|
*/
|
||||||
|
function ModalRoot({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
overlayClassName,
|
|
||||||
closeButtonClassName,
|
|
||||||
disableEscapeKeyDown = false,
|
disableEscapeKeyDown = false,
|
||||||
'aria-label': ariaLabel,
|
'aria-label': ariaLabel,
|
||||||
'aria-labelledby': ariaLabelledBy,
|
'aria-labelledby': ariaLabelledBy,
|
||||||
'aria-describedby': ariaDescribedBy,
|
'aria-describedby': ariaDescribedBy,
|
||||||
}: ModalProps) => {
|
}: ModalProps) {
|
||||||
const handleEscapeKey = useCallback(
|
return (
|
||||||
(event: KeyboardEvent) => {
|
<Dialog
|
||||||
if (event.key === 'Escape' && isOpen && !disableEscapeKeyDown) {
|
open={isOpen}
|
||||||
onClose();
|
onOpenChange={(o) => {
|
||||||
}
|
if (!o) onClose();
|
||||||
},
|
}}
|
||||||
[isOpen, onClose, disableEscapeKeyDown]
|
>
|
||||||
);
|
<DialogContent
|
||||||
|
className={cn('sm:max-w-md', className)}
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
window.addEventListener('keydown', handleEscapeKey);
|
|
||||||
} else {
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
window.removeEventListener('keydown', handleEscapeKey);
|
|
||||||
};
|
|
||||||
}, [isOpen, handleEscapeKey]);
|
|
||||||
|
|
||||||
const modalNode = useMemo(() => document.createElement('div'), []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.body.appendChild(modalNode);
|
|
||||||
return () => {
|
|
||||||
document.body.removeChild(modalNode);
|
|
||||||
};
|
|
||||||
}, [modalNode]);
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div className="fixed inset-0 z-50">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'fixed inset-0 bg-gray-900/80 transition-opacity duration-200',
|
|
||||||
isOpen ? 'opacity-100' : 'opacity-0',
|
|
||||||
overlayClassName
|
|
||||||
)}
|
|
||||||
onClick={onClose}
|
|
||||||
role="presentation"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'fixed left-[50%] top-[50%] z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 bg-[#F0F8FF] rounded-lg p-6 shadow-xl transition-all duration-200',
|
|
||||||
'sm:rounded-lg sm:max-w-md',
|
|
||||||
isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-labelledby={ariaLabelledBy}
|
aria-labelledby={ariaLabelledBy}
|
||||||
aria-describedby={ariaDescribedBy}
|
aria-describedby={ariaDescribedBy}
|
||||||
|
onEscapeKeyDown={(e) => {
|
||||||
|
if (disableEscapeKeyDown) e.preventDefault();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<button
|
</DialogContent>
|
||||||
onClick={onClose}
|
</Dialog>
|
||||||
className={cn(
|
|
||||||
'absolute right-4 top-4 rounded-sm p-1 text-gray-500 transition-colors hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-gray-950 focus:ring-offset-2',
|
|
||||||
closeButtonClassName
|
|
||||||
)}
|
|
||||||
aria-label="Close modal"
|
|
||||||
>
|
|
||||||
<CloseOutlined className="h-4 w-4 cursor-pointer" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
modalNode
|
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
interface ModalHeaderProps {
|
|
||||||
className?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModalHeader = ({ className, children }: ModalHeaderProps) => (
|
type ModalHeaderProps = React.ComponentProps<'div'>;
|
||||||
<div
|
const ModalHeader = ({ className, ...props }: ModalHeaderProps) => (
|
||||||
className={cn(
|
<DialogHeader className={className} {...props} />
|
||||||
'mb-4 flex flex-col space-y-1.5 text-center sm:text-left',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
interface ModalContentProps {
|
type ModalContentProps = React.ComponentProps<'div'>;
|
||||||
className?: string;
|
const ModalContent = ({ className, ...props }: ModalContentProps) => (
|
||||||
children: React.ReactNode;
|
<div className={cn('py-2', className)} {...props} />
|
||||||
}
|
|
||||||
|
|
||||||
const ModalContent = ({ className, children }: ModalContentProps) => (
|
|
||||||
<div className={cn('mb-4', className)}>{children}</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
interface ModalFooterProps {
|
type ModalFooterProps = React.ComponentProps<'div'>;
|
||||||
className?: string;
|
const ModalFooter = ({ className, ...props }: ModalFooterProps) => (
|
||||||
children: React.ReactNode;
|
<DialogFooter className={className} {...props} />
|
||||||
}
|
|
||||||
|
|
||||||
const ModalFooter = ({ className, children }: ModalFooterProps) => (
|
|
||||||
<div className={cn('flex gap-2 sm:flex-row sm:justify-end', className)}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
interface ModalTitleProps {
|
type ModalTitleProps = React.ComponentProps<'h2'>;
|
||||||
className?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ModalTitle = ({ className, children, id }: ModalTitleProps) => (
|
const ModalTitle = ({ className, children, id }: ModalTitleProps) => (
|
||||||
<h2
|
<DialogTitle id={id} className={className}>
|
||||||
id={id}
|
|
||||||
className={cn(
|
|
||||||
'text-lg font-semibold leading-none tracking-tight',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</h2>
|
</DialogTitle>
|
||||||
);
|
);
|
||||||
|
|
||||||
interface ModalDescriptionProps {
|
type ModalDescriptionProps = React.ComponentProps<'p'>;
|
||||||
className?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ModalDescription = ({
|
const ModalDescription = ({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
id,
|
id,
|
||||||
}: ModalDescriptionProps) => (
|
}: ModalDescriptionProps) => (
|
||||||
<p id={id} className={cn('text-sm text-gray-500', className)}>
|
<DialogDescription id={id} className={className}>
|
||||||
{children}
|
{children}
|
||||||
</p>
|
</DialogDescription>
|
||||||
);
|
);
|
||||||
|
|
||||||
Modal.Header = ModalHeader;
|
export const Modal = Object.assign(ModalRoot, {
|
||||||
Modal.Content = ModalContent;
|
Header: ModalHeader,
|
||||||
Modal.Footer = ModalFooter;
|
Content: ModalContent,
|
||||||
Modal.Title = ModalTitle;
|
Footer: ModalFooter,
|
||||||
Modal.Description = ModalDescription;
|
Title: ModalTitle,
|
||||||
|
Description: ModalDescription,
|
||||||
|
});
|
||||||
|
|
||||||
export { Modal };
|
|
||||||
export type {
|
export type {
|
||||||
ModalProps,
|
ModalProps,
|
||||||
ModalHeaderProps,
|
ModalHeaderProps,
|
||||||
|
|||||||
@@ -1,93 +1,109 @@
|
|||||||
import { Table } from '@tanstack/react-table';
|
import { Table } from '@tanstack/react-table';
|
||||||
import { ArrowLeftOutlined, ArrowRightOutlined } from '@ant-design/icons';
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { Button } from '../../atoms/button';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
interface PaginationProps<T> {
|
interface PaginationProps<T> {
|
||||||
table: Table<T>;
|
table: Table<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Pagination = <T,>({ table }: PaginationProps<T>) => {
|
function PageButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center gap-[40px]">
|
<button
|
||||||
<button
|
type="button"
|
||||||
className="disabled:opacity-50 cursor-pointer"
|
onClick={onClick}
|
||||||
onClick={() => table.previousPage()}
|
disabled={disabled}
|
||||||
disabled={!table.getCanPreviousPage()}
|
className={cn(
|
||||||
aria-label="Previous page"
|
'inline-flex size-8 items-center justify-center rounded-md text-sm font-medium transition-colors',
|
||||||
>
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||||
<ArrowLeftOutlined className="text-[16px] text-neutral-800" />
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
</button>
|
active
|
||||||
|
? 'bg-primary-500 text-white hover:bg-primary-600'
|
||||||
|
: 'bg-transparent text-foreground hover:bg-primary-50 hover:text-primary-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<div className="flex gap-4 items-baseline">
|
export const Pagination = <T,>({ table }: PaginationProps<T>) => {
|
||||||
{table.getPageCount() <= 8 ? (
|
const pageIndex = table.getState().pagination.pageIndex;
|
||||||
Array.from({ length: table.getPageCount() }, (_, index) => (
|
const pageCount = table.getPageCount();
|
||||||
<button
|
|
||||||
key={index}
|
if (pageCount <= 1) return null;
|
||||||
className={`size-[30px] py-[8px] flex items-center justify-center rounded-md cursor-pointer ${
|
|
||||||
table.getState().pagination.pageIndex === index
|
const pages: Array<number | 'ellipsis'> = [];
|
||||||
? 'bg-primary-500 text-white'
|
if (pageCount <= 7) {
|
||||||
: 'bg-primary-100 hover:bg-primary-200'
|
for (let i = 0; i < pageCount; i++) pages.push(i);
|
||||||
}`}
|
} else {
|
||||||
onClick={() => table.setPageIndex(index)}
|
pages.push(0);
|
||||||
>
|
if (pageIndex > 3) pages.push('ellipsis');
|
||||||
{index + 1}
|
const start = Math.max(1, pageIndex - 1);
|
||||||
</button>
|
const end = Math.min(pageCount - 2, pageIndex + 1);
|
||||||
))
|
for (let i = start; i <= end; i++) pages.push(i);
|
||||||
) : (
|
if (pageIndex < pageCount - 4) pages.push('ellipsis');
|
||||||
<>
|
pages.push(pageCount - 1);
|
||||||
<button
|
}
|
||||||
onClick={() => table.setPageIndex(0)}
|
|
||||||
className={`size-[30px] py-[8px] flex items-center justify-center rounded-md cursor-pointer ${
|
return (
|
||||||
table.getState().pagination.pageIndex === 0
|
<nav
|
||||||
? 'bg-primary-500 text-white'
|
role="navigation"
|
||||||
: 'bg-primary-100 hover:bg-primary-200'
|
aria-label="Pagination"
|
||||||
}`}
|
className="flex items-center justify-between gap-2 pt-2"
|
||||||
>
|
>
|
||||||
1
|
<div className="text-xs text-muted-foreground">
|
||||||
</button>
|
Page {pageIndex + 1} of {pageCount}
|
||||||
{table.getState().pagination.pageIndex > 3 && <span>...</span>}
|
|
||||||
{Array.from(
|
|
||||||
{ length: 5 },
|
|
||||||
(_, index) => table.getState().pagination.pageIndex - 2 + index
|
|
||||||
)
|
|
||||||
.filter((page) => page > 0 && page < table.getPageCount() - 1)
|
|
||||||
.map((page) => (
|
|
||||||
<button
|
|
||||||
key={page}
|
|
||||||
onClick={() => table.setPageIndex(page)}
|
|
||||||
className={`size-[30px] py-[8px] flex items-center justify-center rounded-md cursor-pointer ${
|
|
||||||
table.getState().pagination.pageIndex === page
|
|
||||||
? 'bg-primary-500 text-white'
|
|
||||||
: 'bg-primary-100 hover:bg-primary-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{page + 1}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{table.getState().pagination.pageIndex <
|
|
||||||
table.getPageCount() - 4 && <span>...</span>}
|
|
||||||
<button
|
|
||||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
|
||||||
className={`size-[30px] py-[8px] flex items-center justify-center rounded-md cursor-pointer ${
|
|
||||||
table.getState().pagination.pageIndex ===
|
|
||||||
table.getPageCount() - 1
|
|
||||||
? 'bg-primary-500 text-white'
|
|
||||||
: 'bg-primary-100 hover:bg-primary-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{table.getPageCount()}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<Button
|
||||||
className="disabled:opacity-50 cursor-pointer"
|
variant="text"
|
||||||
onClick={() => table.nextPage()}
|
size="icon"
|
||||||
disabled={!table.getCanNextPage()}
|
onClick={() => table.previousPage()}
|
||||||
aria-label="Next page"
|
disabled={!table.getCanPreviousPage()}
|
||||||
>
|
aria-label="Previous page"
|
||||||
<ArrowRightOutlined className="text-[16px] text-neutral-800" />
|
>
|
||||||
</button>
|
<ChevronLeft className="size-4" />
|
||||||
</div>
|
</Button>
|
||||||
|
{pages.map((p, idx) =>
|
||||||
|
p === 'ellipsis' ? (
|
||||||
|
<span
|
||||||
|
key={`e-${idx}`}
|
||||||
|
className="px-1 text-sm text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<PageButton
|
||||||
|
key={p}
|
||||||
|
active={pageIndex === p}
|
||||||
|
onClick={() => table.setPageIndex(p)}
|
||||||
|
>
|
||||||
|
{p + 1}
|
||||||
|
</PageButton>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import {
|
import * as React from 'react';
|
||||||
FC,
|
import { NativeSelect } from '../../atoms/select';
|
||||||
ReactElement,
|
import { Label } from '../../atoms/label';
|
||||||
SelectHTMLAttributes,
|
|
||||||
DetailedHTMLProps,
|
|
||||||
} from 'react';
|
|
||||||
import { Select } from '../../atoms'; // Custom select atom kamu
|
|
||||||
import { cn } from '@imphnen-frontend-service/utils';
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
export type TSelectSize = 'sm' | 'md' | 'lg';
|
export type TSelectSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
export type TSelectFieldProps = Omit<
|
export type TSelectFieldProps = Omit<
|
||||||
DetailedHTMLProps<SelectHTMLAttributes<HTMLSelectElement>, HTMLSelectElement>,
|
React.SelectHTMLAttributes<HTMLSelectElement>,
|
||||||
'size'
|
'size'
|
||||||
> & {
|
> & {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -18,76 +14,55 @@ export type TSelectFieldProps = Omit<
|
|||||||
error?: string;
|
error?: string;
|
||||||
helperText?: string;
|
helperText?: string;
|
||||||
htmlFor?: string;
|
htmlFor?: string;
|
||||||
disabled?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeClasses: Record<TSelectSize, { label: string; helperText: string }> = {
|
export const SelectField = React.forwardRef<
|
||||||
lg: {
|
HTMLSelectElement,
|
||||||
label: 'text-p3 font-medium',
|
TSelectFieldProps
|
||||||
helperText: 'text-label3 font-normal',
|
>(
|
||||||
},
|
(
|
||||||
md: {
|
{
|
||||||
label: 'text-label1 font-medium',
|
label,
|
||||||
helperText: 'text-label2 font-normal',
|
size = 'md',
|
||||||
},
|
error,
|
||||||
sm: {
|
helperText,
|
||||||
label: 'text-label2 font-medium',
|
htmlFor,
|
||||||
helperText: 'text-label2 font-normal',
|
className,
|
||||||
},
|
disabled,
|
||||||
};
|
id,
|
||||||
|
children,
|
||||||
export const SelectField: FC<TSelectFieldProps> = ({
|
...rest
|
||||||
label,
|
},
|
||||||
size = 'md',
|
ref
|
||||||
error,
|
) => {
|
||||||
helperText,
|
const autoId = React.useId();
|
||||||
htmlFor,
|
const fieldId = htmlFor ?? id ?? autoId;
|
||||||
disabled,
|
return (
|
||||||
className,
|
<div className="flex flex-col gap-2">
|
||||||
children,
|
<Label htmlFor={fieldId} className="text-sm font-medium text-foreground">
|
||||||
...rest
|
{label}
|
||||||
}): ReactElement => {
|
</Label>
|
||||||
return (
|
<NativeSelect
|
||||||
<div className="flex flex-col gap-[8px]">
|
ref={ref}
|
||||||
<label
|
id={fieldId}
|
||||||
htmlFor={htmlFor}
|
size={size}
|
||||||
className={cn(
|
disabled={disabled}
|
||||||
'items-start justify-item-start text-start !text-neutral-800',
|
aria-invalid={!!error}
|
||||||
sizeClasses[size].label
|
className={cn(
|
||||||
)}
|
error && 'border-destructive focus-visible:ring-destructive/20',
|
||||||
>
|
className
|
||||||
{label}
|
)}
|
||||||
</label>
|
{...rest}
|
||||||
|
>
|
||||||
<Select
|
{children}
|
||||||
{...(htmlFor && { id: htmlFor })}
|
</NativeSelect>
|
||||||
size={size}
|
{error ? (
|
||||||
disabled={disabled}
|
<p className="text-xs text-destructive">{error}</p>
|
||||||
className={cn(
|
) : helperText ? (
|
||||||
error &&
|
<p className="text-xs text-muted-foreground">{helperText}</p>
|
||||||
'border-danger-500 hover:border-danger-500 focus:outline-danger-500',
|
) : null}
|
||||||
className,
|
</div>
|
||||||
disabled && 'opacity-50 cursor-not-allowed'
|
);
|
||||||
)}
|
}
|
||||||
{...rest}
|
);
|
||||||
>
|
SelectField.displayName = 'SelectField';
|
||||||
{children}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="text-danger-500 text-label1 text-left">{error}</p>
|
|
||||||
) : (
|
|
||||||
helperText && (
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'text-label2 text-left',
|
|
||||||
sizeClasses[size].helperText
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{helperText}
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,67 +1,140 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { LogOut, User, Settings } from 'lucide-react';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useAuthStore, useSession } from '@imphnen-frontend-service/service';
|
||||||
import { cn } from '@imphnen-frontend-service/utils';
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
import { Icon } from '@iconify/react';
|
import {
|
||||||
import { FC, ReactElement, ReactNode } from 'react';
|
Avatar,
|
||||||
import { Button } from '../../atoms';
|
AvatarFallback,
|
||||||
import { useAuthStore } from '@imphnen-frontend-service/service';
|
AvatarImage,
|
||||||
|
} from '../../atoms/avatar';
|
||||||
|
import { Button } from '../../atoms/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '../../atoms/dropdown-menu';
|
||||||
|
import { Separator } from '../../atoms/separator';
|
||||||
|
import { SidebarTrigger } from '../../atoms/sidebar';
|
||||||
|
|
||||||
export type TBackofficeWrapperProps = {
|
export type TBackofficeWrapperProps = {
|
||||||
children: ReactNode;
|
children: React.ReactNode;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
classHeader?: string;
|
classHeader?: string;
|
||||||
classTitle?: string;
|
classTitle?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BackofficeWrapper: FC<TBackofficeWrapperProps> = ({
|
export const BackofficeWrapper: React.FC<TBackofficeWrapperProps> = ({
|
||||||
children,
|
children,
|
||||||
title,
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
className,
|
className,
|
||||||
classHeader,
|
classHeader,
|
||||||
classTitle,
|
classTitle,
|
||||||
}): ReactElement => {
|
}) => {
|
||||||
const { session } = useAuthStore();
|
const { session } = useAuthStore();
|
||||||
|
const { signOut } = useSession();
|
||||||
|
const navigate = useNavigate();
|
||||||
const user = session?.user;
|
const user = session?.user;
|
||||||
|
const initials = (user?.fullname ?? 'U')
|
||||||
|
.split(' ')
|
||||||
|
.map((s) => s[0])
|
||||||
|
.slice(0, 2)
|
||||||
|
.join('')
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<div className={cn('flex flex-col', className)}>
|
||||||
className={cn(
|
|
||||||
'w-full px-[48px] py-[40px] flex flex-col gap-8',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-white py-5 px-7 rounded-md shadow flex items-center justify-between',
|
'sticky top-0 z-20 flex h-14 shrink-0 items-center gap-3 border-b border-neutral-200 bg-background/95 px-6 backdrop-blur supports-[backdrop-filter]:bg-background/75',
|
||||||
classHeader
|
classHeader
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<h1
|
<SidebarTrigger className="-ml-1 md:hidden" />
|
||||||
className={cn(
|
<Separator orientation="vertical" className="h-5 md:hidden" />
|
||||||
'text-[19px] text-primary-500 font-semibold',
|
<div className="flex flex-1 items-center gap-3">
|
||||||
classTitle
|
{title && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h1
|
||||||
|
className={cn(
|
||||||
|
'text-base font-semibold leading-tight text-foreground',
|
||||||
|
classTitle
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
{title}
|
<div className="flex items-center gap-3">
|
||||||
</h1>
|
{actions}
|
||||||
|
<DropdownMenu>
|
||||||
<div className="flex items-center gap-x-6">
|
<DropdownMenuTrigger asChild>
|
||||||
<div className="flex items-center gap-x-6">
|
<button
|
||||||
<div className="text-neutral-600 font-medium">
|
type="button"
|
||||||
<p className="text-p3">{user?.fullname || 'Full Name'}</p>
|
className="flex items-center gap-2 rounded-full p-1 transition-colors hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
<p className="text-label1">Admin</p>
|
aria-label="User menu"
|
||||||
</div>
|
>
|
||||||
<div className="size-12 rounded-full overflow-hidden">
|
<div className="hidden text-right md:flex md:flex-col md:leading-tight">
|
||||||
<img
|
<span className="text-sm font-medium text-foreground">
|
||||||
src={user?.avatar || '/images/asd687hwq6nds4dfjj2983.webp'}
|
{user?.fullname ?? 'Admin'}
|
||||||
alt="Profile"
|
</span>
|
||||||
className="size-full object-cover"
|
<span className="text-xs text-muted-foreground">Admin</span>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<Avatar>
|
||||||
</div>
|
<AvatarImage
|
||||||
|
src={user?.avatar || '/images/asd687hwq6nds4dfjj2983.webp'}
|
||||||
|
alt={user?.fullname ?? 'User avatar'}
|
||||||
|
/>
|
||||||
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-52">
|
||||||
|
<DropdownMenuLabel className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{user?.fullname ?? 'Admin'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">
|
||||||
|
{user?.email ?? 'admin@imphnen.dev'}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<User />
|
||||||
|
<span>Profile</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Settings />
|
||||||
|
<span>Settings</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() => {
|
||||||
|
signOut();
|
||||||
|
navigate({ to: '/auth/login' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut />
|
||||||
|
<span>Log out</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<main className="flex-1 space-y-6 px-6 py-6">{children}</main>
|
||||||
<section>{children}</section>
|
</div>
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as React from 'react';
|
||||||
import {
|
import {
|
||||||
PaginationState,
|
PaginationState,
|
||||||
SortingState,
|
SortingState,
|
||||||
@@ -8,17 +9,25 @@ import {
|
|||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
flexRender,
|
flexRender,
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
Table,
|
Table as TanstackTable,
|
||||||
RowData,
|
RowData,
|
||||||
TableOptions,
|
TableOptions,
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
import { Pagination } from '../../molecules';
|
import { ArrowUpDown, ArrowUp, ArrowDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import {
|
||||||
import React from 'react';
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '../../atoms/table';
|
||||||
|
import { Button } from '../../atoms/button';
|
||||||
|
import { Pagination } from '../../molecules/pagination';
|
||||||
import { cn } from '@imphnen-frontend-service/utils';
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
interface DataTableProps<T extends RowData> {
|
interface DataTableProps<T extends RowData> {
|
||||||
table?: Table<T>;
|
table?: TanstackTable<T>;
|
||||||
data?: T[];
|
data?: T[];
|
||||||
columns?: ColumnDef<T, unknown>[];
|
columns?: ColumnDef<T, unknown>[];
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
@@ -27,18 +36,103 @@ interface DataTableProps<T extends RowData> {
|
|||||||
pageCount?: number;
|
pageCount?: number;
|
||||||
currentPage?: number;
|
currentPage?: number;
|
||||||
onPageChange?: (page: number) => void;
|
onPageChange?: (page: number) => void;
|
||||||
|
emptyMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManualPagination({
|
||||||
|
currentPage,
|
||||||
|
pageCount,
|
||||||
|
onPageChange,
|
||||||
|
}: {
|
||||||
|
currentPage: number;
|
||||||
|
pageCount: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}) {
|
||||||
|
if (pageCount <= 1) return null;
|
||||||
|
|
||||||
|
const pages: Array<number | 'ellipsis'> = [];
|
||||||
|
if (pageCount <= 7) {
|
||||||
|
for (let i = 1; i <= pageCount; i++) pages.push(i);
|
||||||
|
} else {
|
||||||
|
pages.push(1);
|
||||||
|
if (currentPage > 3) pages.push('ellipsis');
|
||||||
|
const start = Math.max(2, currentPage - 1);
|
||||||
|
const end = Math.min(pageCount - 1, currentPage + 1);
|
||||||
|
for (let i = start; i <= end; i++) pages.push(i);
|
||||||
|
if (currentPage < pageCount - 2) pages.push('ellipsis');
|
||||||
|
pages.push(pageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="Pagination"
|
||||||
|
className="flex items-center justify-between gap-2 pt-2"
|
||||||
|
>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Page {currentPage} of {pageCount}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
{pages.map((p, idx) =>
|
||||||
|
p === 'ellipsis' ? (
|
||||||
|
<span
|
||||||
|
key={`e-${idx}`}
|
||||||
|
className="px-1 text-sm text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(p)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex size-8 items-center justify-center rounded-md text-sm font-medium transition-colors',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||||
|
currentPage === p
|
||||||
|
? 'bg-primary-500 text-white hover:bg-primary-600'
|
||||||
|
: 'bg-transparent text-foreground hover:bg-primary-50 hover:text-primary-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === pageCount}
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DataTable = <T extends RowData>({
|
export const DataTable = <T extends RowData>({
|
||||||
table,
|
table,
|
||||||
data = [],
|
data = [],
|
||||||
columns = [],
|
columns = [],
|
||||||
pageSize = 9,
|
pageSize = 10,
|
||||||
className,
|
className,
|
||||||
manualPagination = false,
|
manualPagination = false,
|
||||||
pageCount,
|
pageCount,
|
||||||
currentPage = 1,
|
currentPage = 1,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
emptyMessage = 'Tidak ada data',
|
||||||
}: DataTableProps<T>) => {
|
}: DataTableProps<T>) => {
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
@@ -47,18 +141,12 @@ export const DataTable = <T extends RowData>({
|
|||||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setPagination((prev) => ({
|
setPagination((prev) => ({ ...prev, pageSize }));
|
||||||
...prev,
|
|
||||||
pageSize,
|
|
||||||
}));
|
|
||||||
}, [pageSize]);
|
}, [pageSize]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
setPagination((prev) => ({
|
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||||
...prev,
|
|
||||||
pageIndex: 0, // Reset to first page when data changes
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}, [data.length]);
|
}, [data.length]);
|
||||||
|
|
||||||
@@ -69,10 +157,7 @@ export const DataTable = <T extends RowData>({
|
|||||||
const config: TableOptions<T> = {
|
const config: TableOptions<T> = {
|
||||||
data: memoizedData,
|
data: memoizedData,
|
||||||
columns: memoizedColumns,
|
columns: memoizedColumns,
|
||||||
state: {
|
state: { pagination, sorting },
|
||||||
pagination,
|
|
||||||
sorting,
|
|
||||||
},
|
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -82,16 +167,8 @@ export const DataTable = <T extends RowData>({
|
|||||||
manualPagination,
|
manualPagination,
|
||||||
pageCount: manualPagination ? pageCount : undefined,
|
pageCount: manualPagination ? pageCount : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}, [
|
}, [memoizedData, memoizedColumns, pagination, sorting, manualPagination, pageCount]);
|
||||||
memoizedData,
|
|
||||||
memoizedColumns,
|
|
||||||
pagination,
|
|
||||||
sorting,
|
|
||||||
manualPagination,
|
|
||||||
pageCount,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const internalTable = useReactTable(tableConfig);
|
const internalTable = useReactTable(tableConfig);
|
||||||
const t = table ?? internalTable;
|
const t = table ?? internalTable;
|
||||||
@@ -99,189 +176,89 @@ export const DataTable = <T extends RowData>({
|
|||||||
const isEmpty = t.getRowModel().rows.length === 0;
|
const isEmpty = t.getRowModel().rows.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col gap-8', className)}>
|
<div className={cn('flex flex-col gap-4', className)}>
|
||||||
<div className="w-full overflow-x-auto">
|
<div className="rounded-md border border-neutral-200 overflow-hidden">
|
||||||
<table className="w-full min-w-full text-base">
|
<Table>
|
||||||
<thead className="bg-primary-50 mb-3 text-left text-nowrap">
|
<TableHeader className="bg-neutral-50">
|
||||||
{t.getHeaderGroups().map((headerGroup) => (
|
{t.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id}>
|
<TableRow key={headerGroup.id} className="hover:bg-transparent">
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => {
|
||||||
<th
|
const canSort = header.column.getCanSort();
|
||||||
key={header.id}
|
const sorted = header.column.getIsSorted();
|
||||||
onClick={
|
return (
|
||||||
header.column.getCanSort()
|
<TableHead
|
||||||
? header.column.getToggleSortingHandler()
|
key={header.id}
|
||||||
: undefined
|
onClick={
|
||||||
}
|
canSort
|
||||||
className={cn(
|
? header.column.getToggleSortingHandler()
|
||||||
'py-4 px-5 font-normal first:rounded-l-lg last:rounded-r-lg',
|
: undefined
|
||||||
header.column.getCanSort() &&
|
}
|
||||||
'cursor-pointer select-none hover:bg-primary-100 transition-colors',
|
|
||||||
header?.column?.columnDef?.meta?.headerClassName
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
{header.column.getCanSort() && (
|
|
||||||
<span className="ml-2 text-xs text-gray-500">
|
|
||||||
{header.column.getIsSorted() === 'asc' && '▲'}
|
|
||||||
{header.column.getIsSorted() === 'desc' && '▼'}
|
|
||||||
{!header.column.getIsSorted() && <span>⇅</span>}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{isEmpty ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={t.getAllColumns().length}
|
|
||||||
className="py-8 px-5 text-center text-neutral-500"
|
|
||||||
>
|
|
||||||
No data available
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
t.getRowModel().rows.map((row, rowIndex) => (
|
|
||||||
<tr
|
|
||||||
key={row.id}
|
|
||||||
className={cn(
|
|
||||||
'hover:bg-primary-50 transition-colors',
|
|
||||||
rowIndex % 2 === 0 ? 'bg-white' : 'bg-primary-100'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<td
|
|
||||||
key={cell.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'py-3 px-5 first:rounded-l-lg last:rounded-r-lg',
|
canSort && 'cursor-pointer select-none hover:bg-neutral-100',
|
||||||
cell?.column?.columnDef?.meta?.cellClassName
|
header?.column?.columnDef?.meta?.headerClassName
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
{canSort && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{sorted === 'asc' ? (
|
||||||
|
<ArrowUp className="size-3" />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<ArrowDown className="size-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="size-3 opacity-50" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isEmpty ? (
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableCell
|
||||||
|
colSpan={t.getAllColumns().length}
|
||||||
|
className="py-10 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{emptyMessage}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
t.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
className={cell?.column?.columnDef?.meta?.cellClassName}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
cell.getContext()
|
cell.getContext()
|
||||||
)}
|
)}
|
||||||
</td>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
{manualPagination && onPageChange && pageCount ? (
|
{manualPagination && onPageChange && pageCount ? (
|
||||||
<div className="flex items-center justify-center gap-10">
|
<ManualPagination
|
||||||
<button
|
currentPage={currentPage}
|
||||||
className="disabled:opacity-50 cursor-pointer"
|
pageCount={pageCount}
|
||||||
onClick={() => onPageChange(currentPage - 1)}
|
onPageChange={onPageChange}
|
||||||
disabled={currentPage === 1}
|
/>
|
||||||
aria-label="Previous page"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 text-neutral-800"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M15 19l-7-7 7-7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-baseline">
|
|
||||||
{pageCount <= 8 ? (
|
|
||||||
Array.from({ length: pageCount }, (_, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
|
|
||||||
currentPage === index + 1
|
|
||||||
? 'bg-primary-500 text-white'
|
|
||||||
: 'bg-primary-100 hover:bg-primary-200'
|
|
||||||
}`}
|
|
||||||
onClick={() => onPageChange(index + 1)}
|
|
||||||
>
|
|
||||||
{index + 1}
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => onPageChange(1)}
|
|
||||||
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
|
|
||||||
currentPage === 1
|
|
||||||
? 'bg-primary-500 text-white'
|
|
||||||
: 'bg-primary-100 hover:bg-primary-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
1
|
|
||||||
</button>
|
|
||||||
{currentPage > 3 && <span>...</span>}
|
|
||||||
{Array.from(
|
|
||||||
{ length: 5 },
|
|
||||||
(_, index) => currentPage - 2 + index
|
|
||||||
)
|
|
||||||
.filter((page) => page > 1 && page < pageCount)
|
|
||||||
.map((page) => (
|
|
||||||
<button
|
|
||||||
key={page}
|
|
||||||
onClick={() => onPageChange(page)}
|
|
||||||
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
|
|
||||||
currentPage === page
|
|
||||||
? 'bg-primary-500 text-white'
|
|
||||||
: 'bg-primary-100 hover:bg-primary-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{currentPage < pageCount - 2 && <span>...</span>}
|
|
||||||
<button
|
|
||||||
onClick={() => onPageChange(pageCount)}
|
|
||||||
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
|
|
||||||
currentPage === pageCount
|
|
||||||
? 'bg-primary-500 text-white'
|
|
||||||
: 'bg-primary-100 hover:bg-primary-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{pageCount}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="disabled:opacity-50 cursor-pointer"
|
|
||||||
onClick={() => onPageChange(currentPage + 1)}
|
|
||||||
disabled={currentPage === pageCount}
|
|
||||||
aria-label="Next page"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 text-neutral-800"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<Pagination table={t} />
|
<Pagination table={t} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,44 +1,8 @@
|
|||||||
import { CloseOutlined } from '@ant-design/icons';
|
import * as React from 'react';
|
||||||
import { useState } from 'react';
|
import { X } from 'lucide-react';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '../../atoms/radio-group';
|
||||||
interface RadioProps {
|
import { Label } from '../../atoms/label';
|
||||||
checked?: boolean;
|
import { Separator } from '../../atoms/separator';
|
||||||
disabled?: boolean;
|
|
||||||
id: string;
|
|
||||||
label?: string;
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Radio = ({
|
|
||||||
checked,
|
|
||||||
disabled,
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
name,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: RadioProps) => (
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<div className="relative grid place-items-center mt-1">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
id={id}
|
|
||||||
name={name}
|
|
||||||
value={value}
|
|
||||||
checked={checked}
|
|
||||||
onChange={onChange}
|
|
||||||
disabled={disabled}
|
|
||||||
className="peer col-start-1 row-start-1 appearance-none shrink-0 size-[10px] bg-primary-100 rounded-full disabled:border-gray-400"
|
|
||||||
/>
|
|
||||||
<div className="pointer-events-none col-start-1 row-start-1 size-[6px] rounded-full peer-checked:bg-primary-500 peer-checked:peer-disabled:bg-gray-400" />
|
|
||||||
</div>
|
|
||||||
<label htmlFor={id} className="text-start text-neutral-400 text-label2">
|
|
||||||
{label || 'This is the radio label'}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
interface FilterProps {
|
interface FilterProps {
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
@@ -59,42 +23,43 @@ export const Filter = ({
|
|||||||
onFilterChange,
|
onFilterChange,
|
||||||
title = 'Status',
|
title = 'Status',
|
||||||
}: FilterProps) => {
|
}: FilterProps) => {
|
||||||
const [selectedStatus, setSelectedStatus] = useState(
|
const [selectedStatus, setSelectedStatus] = React.useState(
|
||||||
selectedValue || options[0]?.value || ''
|
selectedValue ?? options[0]?.value ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStatusChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleStatusChange = (newValue: string) => {
|
||||||
const newValue = e.target.value;
|
|
||||||
setSelectedStatus(newValue);
|
setSelectedStatus(newValue);
|
||||||
onFilterChange?.(newValue);
|
onFilterChange?.(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex flex-col p-[20px] bg-white rounded-lg gap-4 w-[122px] shadow">
|
<div className="inline-flex min-w-40 flex-col gap-3 rounded-md border border-neutral-200 bg-popover p-4 text-popover-foreground shadow-md">
|
||||||
<div className="flex justify-between items-baseline">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-semibold text-p3 text-primary-500">Filters</span>
|
<span className="text-sm font-semibold text-primary-600">Filters</span>
|
||||||
<button
|
{onClose && (
|
||||||
onClick={onClose}
|
<button
|
||||||
className="cursor-pointer text-neutral-400 hover:text-neutral-600"
|
onClick={onClose}
|
||||||
>
|
className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-neutral-100 hover:text-foreground"
|
||||||
<CloseOutlined className="text-[12px]" />
|
aria-label="Close filter"
|
||||||
</button>
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<hr className="border-primary-200" />
|
<Separator />
|
||||||
<span className="font-semibold text-primary-500">{title}</span>
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
<div className="flex flex-col gap-[10px]">
|
{title}
|
||||||
|
</span>
|
||||||
|
<RadioGroup value={selectedStatus} onValueChange={handleStatusChange}>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<Radio
|
<div key={option.id} className="flex items-center gap-2">
|
||||||
key={option.id}
|
<RadioGroupItem id={option.id} value={option.value} />
|
||||||
id={option.id}
|
<Label htmlFor={option.id} className="text-sm font-normal">
|
||||||
name="status"
|
{option.label}
|
||||||
value={option.value}
|
</Label>
|
||||||
label={option.label}
|
</div>
|
||||||
checked={selectedStatus === option.value}
|
|
||||||
onChange={handleStatusChange}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+1800
File diff suppressed because it is too large
Load Diff
@@ -41,8 +41,27 @@
|
|||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@iconify/react": "^6.0.2",
|
"@iconify/react": "^6.0.2",
|
||||||
"@marsidev/react-turnstile": "^1.5.0",
|
"@marsidev/react-turnstile": "^1.5.0",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.4",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.7",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.4",
|
||||||
|
"@radix-ui/react-checkbox": "^1.1.5",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.4",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.7",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.7",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.7",
|
||||||
|
"@radix-ui/react-progress": "^1.1.3",
|
||||||
|
"@radix-ui/react-radio-group": "^1.2.4",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.4",
|
||||||
|
"@radix-ui/react-select": "^2.1.7",
|
||||||
|
"@radix-ui/react-separator": "^1.1.3",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.1.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.4",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.3",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.7",
|
||||||
"@redocly/ajv": "^8.18.1",
|
"@redocly/ajv": "^8.18.1",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@tanstack/react-query": "^5.95.2",
|
"@tanstack/react-query": "^5.95.2",
|
||||||
@@ -54,11 +73,14 @@
|
|||||||
"axios": "^1.14.0",
|
"axios": "^1.14.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"graphql": "^16.13.2",
|
"graphql": "^16.13.2",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
|
"lucide-react": "^0.469.0",
|
||||||
"openapi-fetch": "^0.17.0",
|
"openapi-fetch": "^0.17.0",
|
||||||
"openapi-react-query": "^0.5.4",
|
"openapi-react-query": "^0.5.4",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user