feat: authorization admin panel

This commit is contained in:
pahmiudahgede 2025-07-09 08:21:10 +07:00
parent fd740a99c3
commit c35b1c85df
5 changed files with 343 additions and 416 deletions

View File

@ -1,6 +1,5 @@
// app/components/layoutadmin/header.tsx
import { useState } from "react";
import { Form } from "@remix-run/react";
import { Form, useNavigation } from "@remix-run/react";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
@ -14,6 +13,16 @@ import {
DropdownMenuTrigger
} from "~/components/ui/dropdown-menu";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from "~/components/ui/alert-dialog";
import {
Menu,
Search,
@ -26,23 +35,38 @@ import {
ChevronDown,
MoreHorizontal,
PanelLeftClose,
PanelLeft
PanelLeft,
Loader2,
Shield
} from "lucide-react";
import { SessionData } from "~/sessions.server";
interface AdminHeaderProps {
onMenuClick: () => void;
sidebarCollapsed: boolean;
isMobile: boolean;
user: SessionData;
}
export function AdminHeader({
onMenuClick,
sidebarCollapsed,
isMobile
isMobile,
user
}: AdminHeaderProps) {
const [isDark, setIsDark] = useState(false);
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const navigation = useNavigation();
const isLoggingOut = navigation.formAction === "/action/logout";
const getUserInitials = (email: string) => {
if (email) {
return email.substring(0, 2).toUpperCase();
}
return "AD";
};
return (
<>
<header className="sticky top-0 flex w-full bg-white border-b border-gray-200 z-40 dark:border-gray-700 dark:bg-gray-800">
<div className="flex flex-col items-center justify-between grow lg:flex-row lg:px-6">
{/* Mobile header */}
@ -65,8 +89,11 @@ export function AdminHeader({
{/* Mobile logo */}
<div className="lg:hidden">
<div className="w-24 h-6 bg-gray-800 dark:bg-white rounded flex items-center justify-center text-white dark:text-gray-900 text-xs font-bold">
LOGO
<div className="flex items-center space-x-2">
<Shield className="h-5 w-5 text-blue-600" />
<div className="text-sm font-bold text-gray-900 dark:text-white">
Admin Panel
</div>
</div>
</div>
@ -83,30 +110,23 @@ export function AdminHeader({
<Bell className="mr-2 h-4 w-4" />
Notifications
<Badge variant="destructive" className="ml-auto">
3
5
</Badge>
</Button>
<ModeToggle />
{/* Mobile Logout */}
<Button
variant="outline"
className="w-full text-red-600 border-red-200 hover:bg-red-50"
onClick={() => setShowLogoutDialog(true)}
>
<LogOut className="mr-2 h-4 w-4" />
Logout
</Button>
</div>
</SheetContent>
</Sheet>
{/* Desktop search - Commented out for now */}
{/* <div className="hidden lg:block">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500 dark:text-gray-400" />
<Input
type="text"
placeholder="Search or type command..."
className="w-96 pl-10 pr-14 bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<kbd className="inline-flex items-center gap-1 rounded border border-gray-200 bg-white px-1.5 py-0.5 text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
K
</kbd>
</div>
</div>
</div> */}
</div>
{/* Desktop header actions */}
@ -128,7 +148,7 @@ export function AdminHeader({
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 rounded-full p-0 text-xs flex items-center justify-center animate-pulse"
>
3
5
</Badge>
</Button>
</DropdownMenuTrigger>
@ -138,36 +158,36 @@ export function AdminHeader({
>
<div className="p-4">
<h4 className="font-semibold mb-2 flex items-center justify-between text-gray-900 dark:text-gray-100">
Notifications
Admin Notifications
<Badge variant="secondary" className="text-xs">
3 new
5 new
</Badge>
</h4>
<div className="space-y-2">
<div className="text-sm text-gray-600 dark:text-gray-200 p-3 rounded-lg bg-gray-50 dark:bg-gray-700 border-l-4 border-blue-500">
<div className="text-sm text-gray-600 dark:text-gray-200 p-3 rounded-lg bg-gray-50 dark:bg-gray-700 border-l-4 border-red-500">
<p className="font-medium text-gray-900 dark:text-gray-100">
New user registered
New user verification required
</p>
<p className="text-xs text-gray-500 dark:text-gray-300 mt-1">
2 minutes ago
</p>
</div>
<div className="text-sm text-gray-600 dark:text-gray-200 p-3 rounded-lg bg-gray-50 dark:bg-gray-700 border-l-4 border-blue-500">
<p className="font-medium text-gray-900 dark:text-gray-100">
New pengelola registration
</p>
<p className="text-xs text-gray-500 dark:text-gray-300 mt-1">
15 minutes ago
</p>
</div>
<div className="text-sm text-gray-600 dark:text-gray-200 p-3 rounded-lg bg-gray-50 dark:bg-gray-700 border-l-4 border-green-500">
<p className="font-medium text-gray-900 dark:text-gray-100">
System update available
System backup completed
</p>
<p className="text-xs text-gray-500 dark:text-gray-300 mt-1">
1 hour ago
</p>
</div>
<div className="text-sm text-gray-600 dark:text-gray-200 p-3 rounded-lg bg-gray-50 dark:bg-gray-700 border-l-4 border-orange-500">
<p className="font-medium text-gray-900 dark:text-gray-100">
New message received
</p>
<p className="text-xs text-gray-500 dark:text-gray-300 mt-1">
3 hours ago
</p>
</div>
</div>
<Button variant="outline" className="w-full mt-3 text-xs">
View all notifications
@ -185,20 +205,17 @@ export function AdminHeader({
>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage
src="https://github.com/shadcn.png"
alt="User"
/>
<AvatarImage src="" alt="Admin" />
<AvatarFallback className="bg-blue-600 text-white">
MU
{getUserInitials(user.email || "Admin")}
</AvatarFallback>
</Avatar>
<div className="hidden sm:block text-left">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
Musharof
Administrator
</div>
<div className="text-xs text-gray-500 dark:text-gray-300">
Administrator
{user.email || "admin@example.com"}
</div>
</div>
<ChevronDown className="h-4 w-4 text-gray-500" />
@ -210,31 +227,27 @@ export function AdminHeader({
className="w-56 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
>
<div className="px-2 py-1.5 text-sm text-gray-700 dark:text-gray-200">
<div className="font-medium">Musharof</div>
<div className="font-medium">Administrator</div>
<div className="text-xs text-gray-500 dark:text-gray-300">
admin@example.com
{user.email || "admin@example.com"}
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer">
<User className="mr-2 h-4 w-4" />
Profile
Admin Profile
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<Settings className="mr-2 h-4 w-4" />
Settings
System Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Form action="/logout" method="post" className="w-full">
<button
type="submit"
className="flex w-full items-center text-red-600 dark:text-red-400 cursor-pointer"
<DropdownMenuItem
className="cursor-pointer text-red-600 dark:text-red-400"
onClick={() => setShowLogoutDialog(true)}
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</button>
</Form>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -242,5 +255,38 @@ export function AdminHeader({
</div>
</div>
</header>
{/* Logout Confirmation Dialog */}
<AlertDialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Konfirmasi Logout Admin</AlertDialogTitle>
<AlertDialogDescription>
Apakah Anda yakin ingin keluar dari admin panel? Anda perlu login
kembali untuk mengakses sistem administrasi.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoggingOut}>Batal</AlertDialogCancel>
<Form method="post" action="/action/logout">
<AlertDialogAction
type="submit"
className="bg-red-600 hover:bg-red-700"
disabled={isLoggingOut}
>
{isLoggingOut ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Logging out...
</>
) : (
"Ya, Logout"
)}
</AlertDialogAction>
</Form>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@ -1,13 +1,17 @@
// app/components/layoutadmin/layout-wrapper.tsx
import { useState, useEffect } from "react";
import { AdminSidebar } from "./sidebar";
import { AdminHeader } from "./header";
import { SessionData } from "~/sessions.server";
interface AdminLayoutWrapperProps {
children: React.ReactNode;
user: SessionData;
}
export function AdminLayoutWrapper({ children }: AdminLayoutWrapperProps) {
export function AdminLayoutWrapper({
children,
user
}: AdminLayoutWrapperProps) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [isHovered, setIsHovered] = useState(false);
@ -69,6 +73,7 @@ export function AdminLayoutWrapper({ children }: AdminLayoutWrapperProps) {
onMenuClick={handleToggleSidebar}
sidebarCollapsed={sidebarCollapsed}
isMobile={isMobile}
user={user}
/>
{/* Page content */}

View File

@ -3,7 +3,6 @@ import { Link, useLocation } from "@remix-run/react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Separator } from "~/components/ui/separator";
import {
Collapsible,
CollapsibleContent,
@ -19,21 +18,12 @@ import {
LayoutDashboard,
Recycle,
Users,
TrendingUp,
FileText,
CreditCard,
BarChart3,
Settings,
MessageCircle,
MapPin,
Package,
DollarSign,
UserCheck,
Building2,
Newspaper,
HelpCircle,
Bell,
Shield,
ChevronDown,
X
} from "lucide-react";
@ -62,197 +52,58 @@ const menuItems: MenuItem[] = [
{
title: "Dashboard",
icon: <LayoutDashboard className="w-5 h-5" />,
children: [
{
title: "Overview",
href: "/sys-rijig-adminpanel/dashboard"
},
{ title: "Analytics", href: "/admin/analytics" },
{ title: "Statistik Sampah", href: "/admin/waste-stats", badge: "new" },
{ title: "Laporan Harian", href: "/admin/daily-reports" }
]
},
{
title: "Data Sampah",
icon: <Recycle className="w-5 h-5" />,
children: [
{ title: "Jenis Sampah", href: "/sys-rijig-adminpanel/dashboard/waste" },
{ title: "Harga Sampah", href: "/admin/waste-prices" },
{ title: "Volume Sampah", href: "/admin/waste-volume" },
{ title: "Tracking Sampah", href: "/admin/waste-tracking", badge: "new" },
{ title: "Kualitas Sampah", href: "/admin/waste-quality" }
]
href: "/sys-rijig-adminpanel/dashboard/waste"
},
{
title: "Manajemen User",
icon: <Users className="w-5 h-5" />,
children: [
{ title: "Masyarakat", href: "/admin/users/community" },
{ title: "Pengepul", href: "/admin/users/collectors" },
{ title: "Pengelola Daur Ulang", href: "/admin/users/recyclers" },
{
title: "Verifikasi User",
href: "/sys-rijig-adminpanel/dashboard/users",
badge: "urgent"
},
{ title: "Rating & Review", href: "/admin/users/reviews" }
]
{
title: "Masyarakat",
href: "/sys-rijig-adminpanel/masyarakat"
},
{
title: "Transaksi",
icon: <CreditCard className="w-5 h-5" />,
children: [
{ title: "Semua Transaksi", href: "/admin/transactions/all" },
{
title: "Pembayaran Pending",
href: "/admin/transactions/pending",
badge: "urgent"
title: "Pengepul",
href: "/sys-rijig-adminpanel/pengepul"
},
{ title: "Riwayat Pembayaran", href: "/admin/transactions/history" },
{ title: "Komisi & Fee", href: "/admin/transactions/commission" },
{
title: "Laporan Keuangan",
href: "/admin/transactions/financial-report"
title: "Pengelola",
href: "/sys-rijig-adminpanel/pengelola"
}
]
}
];
const contentMenuItems: MenuItem[] = [
{
title: "Content Management",
icon: <FileText className="w-5 h-5" />,
children: [
{ title: "Artikel & Blog", href: "/sys-rijig-adminpanel/dashboard/artikel-blog" },
{ title: "Tips & Panduan", href: "/sys-rijig-adminpanel/dashboard/tips-panduan" },
{ title: "FAQ", href: "/admin/content/faq" },
{
title: "Pengumuman",
href: "/admin/content/announcements",
badge: "new"
},
{ title: "Testimoni", href: "/admin/content/testimonials" }
]
},
{
title: "Lokasi & Mapping",
icon: <MapPin className="w-5 h-5" />,
children: [
{ title: "Peta Pengepul", href: "/admin/mapping/collectors" },
{ title: "Area Coverage", href: "/sys-rijig-adminpanel/dashboard/areacoverage" },
{ title: "Titik Pengumpulan", href: "/admin/mapping/collection-points" },
{ title: "Rute Optimal", href: "/admin/mapping/routes", badge: "new" }
]
title: "Artikel & Blog",
icon: <Newspaper className="w-5 h-5" />,
href: "/sys-rijig-adminpanel/dashboard/artikel-blog"
},
{
title: "Notifikasi",
icon: <Bell className="w-5 h-5" />,
children: [
{ title: "Push Notifications", href: "/admin/notifications/push" },
{ title: "Email Broadcast", href: "/admin/notifications/email" },
{ title: "SMS Gateway", href: "/admin/notifications/sms" },
{ title: "Template Pesan", href: "/admin/notifications/templates" }
]
}
];
const analyticsMenuItems: MenuItem[] = [
{
title: "Reports & Analytics",
icon: <BarChart3 className="w-5 h-5" />,
children: [
{ title: "Laporan Bulanan", href: "/admin/reports/monthly" },
{
title: "Performa Pengepul",
href: "/admin/reports/collector-performance"
},
{ title: "Tren Harga", href: "/admin/reports/price-trends" },
{
title: "Dampak Lingkungan",
href: "/admin/reports/environmental-impact",
badge: "new"
},
{ title: "ROI Analysis", href: "/admin/reports/roi-analysis" }
]
},
{
title: "Partner & Kerjasama",
icon: <Building2 className="w-5 h-5" />,
children: [
{ title: "Bank Sampah", href: "/admin/partners/waste-banks" },
{
title: "Industri Daur Ulang",
href: "/admin/partners/recycling-industry"
},
{ title: "Pemerintah Daerah", href: "/admin/partners/government" },
{ title: "NGO & Komunitas", href: "/admin/partners/ngo-community" }
]
},
{
title: "Support & Help",
title: "Tips & Panduan",
icon: <HelpCircle className="w-5 h-5" />,
children: [
{
title: "Tiket Support",
href: "/admin/support/tickets",
badge: "urgent"
href: "/sys-rijig-adminpanel/dashboard/tips-panduan"
},
{ title: "Live Chat", href: "/admin/support/chat" },
{ title: "Knowledge Base", href: "/admin/support/knowledge-base" },
{ title: "Training Materials", href: "/admin/support/training" }
]
{
title: "Area Coverage",
icon: <MapPin className="w-5 h-5" />,
href: "/sys-rijig-adminpanel/dashboard/areacoverage"
},
{
title: "Pengaturan",
icon: <Settings className="w-5 h-5" />,
children: [
{ title: "Konfigurasi Sistem", href: "/sys-rijig-adminpanel/dashboard/pengaturan" },
{ title: "Pengaturan Harga", href: "/admin/settings/pricing" },
{
title: "Role & Permission",
href: "/admin/settings/roles",
badge: "pro"
},
{ title: "Backup & Restore", href: "/admin/settings/backup" },
{ title: "API Management", href: "/admin/settings/api", badge: "pro" }
]
href: "/sys-rijig-adminpanel/dashboard/pengaturan"
}
];
function MenuSection({
title,
items,
isCollapsed,
isHovered
}: {
title: string;
items: MenuItem[];
isCollapsed: boolean;
isHovered: boolean;
}) {
const showText = !isCollapsed || isHovered;
return (
<div className="space-y-2">
{showText && (
<h3 className="mb-4 px-3 text-xs uppercase text-gray-500 dark:text-gray-300 font-semibold tracking-wider">
{title}
</h3>
)}
<ul className="space-y-1">
{items.map((item, index) => (
<MenuItemComponent
key={index}
item={item}
isCollapsed={isCollapsed}
isHovered={isHovered}
/>
))}
</ul>
</div>
);
}
function MenuItemComponent({
item,
isCollapsed,
@ -292,7 +143,8 @@ function MenuItemComponent({
variant="ghost"
className={cn(
"w-full h-12 p-0 justify-center transition-colors",
isChildActive && "bg-green-100 dark:bg-green-900/30"
(isChildActive || pathname === item.href) &&
"bg-green-100 dark:bg-green-900/30"
)}
asChild={!hasChildren}
>
@ -437,13 +289,14 @@ function MenuItemComponent({
asChild
variant="ghost"
className={cn(
"w-full justify-start px-3 py-2.5 h-auto transition-colors",
"w-full justify-between px-3 py-2.5 h-auto transition-colors",
"hover:bg-gray-100 dark:hover:bg-gray-700",
isActive &&
"bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/40 dark:text-green-400 dark:hover:bg-green-900/50"
)}
>
<Link to={item.href || "#"}>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
<span
className={cn(
@ -466,6 +319,27 @@ function MenuItemComponent({
</span>
)}
</div>
{showText && item.badge && (
<Badge
variant={
item.badge === "urgent"
? "destructive"
: item.badge === "pro"
? "secondary"
: "default"
}
className={cn(
"text-xs ml-2",
item.badge === "urgent" &&
"bg-red-100 text-red-700 hover:bg-red-200",
item.badge === "new" &&
"bg-green-100 text-green-700 hover:bg-green-200"
)}
>
{item.badge}
</Badge>
)}
</div>
</Link>
</Button>
</li>
@ -500,12 +374,15 @@ export function AdminSidebar({
{/* Logo */}
<div className="h-16 flex items-center justify-between px-4 border-b border-gray-200 dark:border-gray-700">
{showText && (
<Link to="/admin/dashboard" className="flex items-center gap-2">
<Link
to="/sys-rijig-adminpanel/dashboard"
className="flex items-center gap-2"
>
<div className="w-8 h-8 bg-green-600 rounded-lg flex items-center justify-center text-white text-sm font-bold">
</div>
<div className="text-lg font-bold text-gray-900 dark:text-white">
RIjig Admin
Rijig Admin
</div>
</Link>
)}
@ -530,25 +407,25 @@ export function AdminSidebar({
{/* Navigation */}
<ScrollArea className="flex-1 h-[calc(100vh-64px)]">
<div className="py-6 px-3 space-y-6">
<MenuSection
title="Core Management"
items={menuItems}
isCollapsed={isCollapsed}
isHovered={isHovered}
/>
<MenuSection
title="Content & Communications"
items={contentMenuItems}
isCollapsed={isCollapsed}
isHovered={isHovered}
/>
<MenuSection
title="Analytics & Administration"
items={analyticsMenuItems}
<div className="py-6 px-3">
{/* Main Menu */}
<div className="space-y-1">
{showText && (
<h3 className="mb-4 px-3 text-xs uppercase text-gray-500 dark:text-gray-300 font-semibold tracking-wider">
Admin Panel
</h3>
)}
<ul className="space-y-1">
{menuItems.map((item, index) => (
<MenuItemComponent
key={index}
item={item}
isCollapsed={isCollapsed}
isHovered={isHovered}
/>
))}
</ul>
</div>
</div>
{/* Footer CTA - Only show when not collapsed or when hovered */}
@ -557,7 +434,7 @@ export function AdminSidebar({
<div className="flex items-center gap-2 mb-2">
<Recycle className="w-4 h-4 text-green-600 dark:text-green-400" />
<h4 className="font-semibold text-green-900 dark:text-green-100 text-sm">
RIjig Dashboard
Rijig Dashboard
</h4>
</div>
<p className="mb-4 text-green-700 dark:text-green-200 text-xs">

View File

@ -19,8 +19,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
});
}
// pada kode app/routes/pengelola.tsx pada bagian:
export default function PengelolaPanelLayout() {
const { user } = useLoaderData<LoaderData>();
return (
@ -29,7 +27,3 @@ export default function PengelolaPanelLayout() {
</PengelolaLayoutWrapper>
);
}
/* terdapat error ini: Type '{ children: Element; user: SessionData; }' is not assignable to type 'IntrinsicAttributes & PengelolaLayoutWrapperProps'.
Property 'user' does not exist on type 'IntrinsicAttributes & PengelolaLayoutWrapperProps'. */

View File

@ -1,24 +1,29 @@
import { json } from "@remix-run/node";
import { json, LoaderFunctionArgs } from "@remix-run/node";
import { Outlet, useLoaderData } from "@remix-run/react";
import { AdminLayoutWrapper } from "~/components/layoutadmin/layout-wrapper";
import { requireUserSession, SessionData } from "~/sessions.server";
export const loader = async () => {
// Data untuk layout bisa diambil di sini
return json({
user: {
name: "Musharof",
email: "admin@example.com",
role: "Administrator"
interface LoaderData {
user: SessionData;
}
export async function loader({ request }: LoaderFunctionArgs) {
const userSession = await requireUserSession(
request,
"administrator",
"complete"
);
return json<LoaderData>({
user: userSession
});
};
}
export default function AdminPanelLayout() {
const { user } = useLoaderData<typeof loader>();
return (
<AdminLayoutWrapper>
{/* Outlet akan merender child routes */}
<AdminLayoutWrapper user={user}>
<Outlet />
</AdminLayoutWrapper>
);