initial commit:migrate next js to remix

This commit is contained in:
pahmiudahgede 2025-07-06 13:25:02 +07:00
parent b0dcb68132
commit 15f418d803
59 changed files with 12445 additions and 272 deletions

View File

@ -0,0 +1,246 @@
// app/components/layoutadmin/header.tsx
import { useState } from "react";
import { Form } from "@remix-run/react";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Badge } from "~/components/ui/badge";
import { ModeToggle } from "~/components/ui/dark-mode-toggle";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "~/components/ui/dropdown-menu";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import {
Menu,
Search,
Bell,
Sun,
Moon,
User,
Settings,
LogOut,
ChevronDown,
MoreHorizontal,
PanelLeftClose,
PanelLeft
} from "lucide-react";
interface AdminHeaderProps {
onMenuClick: () => void;
sidebarCollapsed: boolean;
isMobile: boolean;
}
export function AdminHeader({
onMenuClick,
sidebarCollapsed,
isMobile
}: AdminHeaderProps) {
const [isDark, setIsDark] = useState(false);
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 */}
<div className="flex items-center justify-between w-full gap-2 px-3 py-3 border-b border-gray-200 dark: sm:gap-4 lg:justify-normal lg:border-b-0 lg:px-0 lg:py-4">
{/* Hamburger menu button */}
<Button
variant="outline"
size="icon"
onClick={onMenuClick}
className="hover:bg-gray-100 dark:hover:bg-gray-700 border-gray-300 dark:border-gray-600 dark:bg-gray-800"
>
{isMobile ? (
<Menu className="h-5 w-5" />
) : sidebarCollapsed ? (
<PanelLeft className="h-5 w-5" />
) : (
<PanelLeftClose className="h-5 w-5" />
)}
</Button>
{/* 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>
</div>
{/* Mobile more menu */}
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="lg:hidden">
<MoreHorizontal className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-80">
<div className="mt-6 space-y-4">
<Button className="w-full" variant="outline">
<Bell className="mr-2 h-4 w-4" />
Notifications
<Badge variant="destructive" className="ml-auto">
3
</Badge>
</Button>
<ModeToggle />
</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 */}
<div className="hidden items-center justify-between w-full gap-4 px-5 py-4 lg:flex lg:justify-end lg:px-0">
<div className="flex items-center gap-3">
{/* Theme toggle */}
<ModeToggle />
{/* Notifications */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="relative hover:bg-gray-100 dark:hover:bg-gray-700 border-gray-300 dark:border-gray-600 dark:bg-gray-800"
>
<Bell className="h-4 w-4" />
<Badge
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
</Badge>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-80 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
>
<div className="p-4">
<h4 className="font-semibold mb-2 flex items-center justify-between text-gray-900 dark:text-gray-100">
Notifications
<Badge variant="secondary" className="text-xs">
3 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">
<p className="font-medium text-gray-900 dark:text-gray-100">
New user registered
</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-green-500">
<p className="font-medium text-gray-900 dark:text-gray-100">
System update available
</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
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* User menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-auto p-2 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage
src="https://github.com/shadcn.png"
alt="User"
/>
<AvatarFallback className="bg-blue-600 text-white">
MU
</AvatarFallback>
</Avatar>
<div className="hidden sm:block text-left">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
Musharof
</div>
<div className="text-xs text-gray-500 dark:text-gray-300">
Administrator
</div>
</div>
<ChevronDown className="h-4 w-4 text-gray-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
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="text-xs text-gray-500 dark:text-gray-300">
admin@example.com
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer">
<User className="mr-2 h-4 w-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<Settings className="mr-2 h-4 w-4" />
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"
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</button>
</Form>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,79 @@
// app/components/layoutadmin/layout-wrapper.tsx
import { useState, useEffect } from "react";
import { AdminSidebar } from "./sidebar";
import { AdminHeader } from "./header";
interface AdminLayoutWrapperProps {
children: React.ReactNode;
}
export function AdminLayoutWrapper({ children }: AdminLayoutWrapperProps) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 1024);
if (window.innerWidth >= 1024) {
setSidebarOpen(false);
}
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
const handleToggleSidebar = () => {
if (isMobile) {
setSidebarOpen(!sidebarOpen);
} else {
setSidebarCollapsed(!sidebarCollapsed);
}
};
const sidebarWidth = sidebarCollapsed && !isHovered ? "80px" : "290px";
const contentMargin = isMobile ? "0" : sidebarWidth;
return (
<div className="min-h-screen flex bg-gray-50 dark:bg-gray-900">
{/* Sidebar */}
<AdminSidebar
isOpen={sidebarOpen}
isCollapsed={sidebarCollapsed}
isHovered={isHovered}
isMobile={isMobile}
onClose={() => setSidebarOpen(false)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
/>
{/* Mobile overlay */}
{sidebarOpen && isMobile && (
<div
className="fixed inset-0 z-40 bg-gray-900/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Main content */}
<div
className="flex-1 flex flex-col transition-all duration-300 ease-in-out"
style={{
marginLeft: isMobile ? "0" : contentMargin
}}
>
<AdminHeader
onMenuClick={handleToggleSidebar}
sidebarCollapsed={sidebarCollapsed}
isMobile={isMobile}
/>
{/* Page content */}
<main className="flex-1 p-4 lg:p-6">{children}</main>
</div>
</div>
);
}

View File

@ -0,0 +1,587 @@
import { useState, useMemo } from "react";
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,
CollapsibleTrigger
} from "~/components/ui/collapsible";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "~/components/ui/tooltip";
import {
LayoutDashboard,
Recycle,
Users,
TrendingUp,
FileText,
CreditCard,
BarChart3,
Settings,
MessageCircle,
MapPin,
Package,
DollarSign,
UserCheck,
Building2,
Newspaper,
HelpCircle,
Bell,
Shield,
ChevronDown,
X
} from "lucide-react";
import { cn } from "~/lib/utils";
interface SidebarProps {
isOpen: boolean;
isCollapsed: boolean;
isHovered: boolean;
isMobile: boolean;
onClose: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
interface MenuItem {
title: string;
icon?: React.ReactNode;
href?: string;
badge?: string;
badgeVariant?: "pro" | "new" | "urgent";
children?: MenuItem[];
}
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" }
]
},
{
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: "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: "Riwayat Pembayaran", href: "/admin/transactions/history" },
{ title: "Komisi & Fee", href: "/admin/transactions/commission" },
{
title: "Laporan Keuangan",
href: "/admin/transactions/financial-report"
}
]
}
];
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: "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",
icon: <HelpCircle className="w-5 h-5" />,
children: [
{
title: "Tiket Support",
href: "/admin/support/tickets",
badge: "urgent"
},
{ title: "Live Chat", href: "/admin/support/chat" },
{ title: "Knowledge Base", href: "/admin/support/knowledge-base" },
{ title: "Training Materials", href: "/admin/support/training" }
]
},
{
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" }
]
}
];
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,
isHovered
}: {
item: MenuItem;
isCollapsed: boolean;
isHovered: boolean;
}) {
const location = useLocation();
const pathname = location.pathname;
const [isOpen, setIsOpen] = useState(false);
const hasChildren = item.children && item.children.length > 0;
const showText = !isCollapsed || isHovered;
// Check if any child is active
const isChildActive = useMemo(() => {
if (!hasChildren) return false;
return item.children!.some((child) => child.href === pathname);
}, [hasChildren, item.children, pathname]);
// Auto open if child is active
useMemo(() => {
if (isChildActive && !isOpen) {
setIsOpen(true);
}
}, [isChildActive, isOpen]);
// For collapsed state without hover, show tooltip
if (isCollapsed && !isHovered) {
return (
<li>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
className={cn(
"w-full h-12 p-0 justify-center transition-colors",
isChildActive && "bg-green-100 dark:bg-green-900/30"
)}
asChild={!hasChildren}
>
{hasChildren ? (
<div
className={cn(
"text-gray-700 dark:text-gray-100",
isChildActive && "text-green-700 dark:text-green-400"
)}
>
{item.icon}
</div>
) : (
<Link to={item.href || "#"}>
<div
className={cn(
"text-gray-700 dark:text-gray-100",
pathname === item.href &&
"text-green-700 dark:text-green-400"
)}
>
{item.icon}
</div>
</Link>
)}
</Button>
</TooltipTrigger>
<TooltipContent side="right" className="ml-2">
<p>{item.title}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</li>
);
}
if (hasChildren) {
return (
<li>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className={cn(
"w-full justify-between px-3 py-2.5 h-auto transition-colors",
"hover:bg-gray-100 dark:hover:bg-gray-700",
isChildActive &&
"bg-green-50 text-green-700 hover:bg-green-100 dark:bg-green-900/20 dark:text-green-400 dark:hover:bg-green-900/30"
)}
>
<div className="flex items-center gap-3">
<span
className={cn(
"transition-colors",
isChildActive
? "text-green-600 dark:text-green-400"
: "text-gray-700 dark:text-gray-100"
)}
>
{item.icon}
</span>
{showText && (
<span
className={cn(
"text-sm font-medium text-gray-900 dark:text-gray-100",
isChildActive && "text-green-700 dark:text-green-400"
)}
>
{item.title}
</span>
)}
</div>
{showText && (
<ChevronDown
className={cn(
"h-4 w-4 transition-transform",
isOpen && "rotate-180",
isChildActive && "text-green-600 dark:text-green-400"
)}
/>
)}
</Button>
</CollapsibleTrigger>
{showText && (
<CollapsibleContent className="ml-8 mt-1 space-y-1">
{item.children?.map((child, childIndex) => {
const isActive = pathname === child.href;
return (
<Link
key={childIndex}
to={child.href || "#"}
className={cn(
"flex items-center justify-between px-3 py-2 text-sm rounded-md transition-colors",
"hover:bg-gray-100 dark:hover:bg-gray-700",
isActive
? "bg-green-100 text-green-700 font-medium hover:bg-green-200 dark:bg-green-900/40 dark:text-green-400 dark:hover:bg-green-900/50"
: "text-gray-600 dark:text-gray-200"
)}
>
<span className="flex items-center gap-2">
{isActive && (
<div className="w-1 h-4 bg-green-600 rounded-full" />
)}
{child.title}
</span>
{child.badge && (
<Badge
variant={
child.badge === "urgent"
? "destructive"
: child.badge === "pro"
? "secondary"
: "default"
}
className={cn(
"text-xs",
child.badge === "urgent" &&
"bg-red-100 text-red-700 hover:bg-red-200",
child.badge === "new" &&
"bg-green-100 text-green-700 hover:bg-green-200"
)}
>
{child.badge}
</Badge>
)}
</Link>
);
})}
</CollapsibleContent>
)}
</Collapsible>
</li>
);
}
// Single menu item (no children)
const isActive = pathname === item.href;
return (
<li>
<Button
asChild
variant="ghost"
className={cn(
"w-full justify-start 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 gap-3">
<span
className={cn(
"transition-colors",
isActive
? "text-green-600 dark:text-green-400"
: "text-gray-700 dark:text-gray-100"
)}
>
{item.icon}
</span>
{showText && (
<span
className={cn(
"text-sm font-medium text-gray-900 dark:text-gray-100",
isActive && "text-green-700 dark:text-green-400"
)}
>
{item.title}
</span>
)}
</div>
</Link>
</Button>
</li>
);
}
export function AdminSidebar({
isOpen,
isCollapsed,
isHovered,
isMobile,
onClose,
onMouseEnter,
onMouseLeave
}: SidebarProps) {
const sidebarWidth = isCollapsed && !isHovered ? "w-20" : "w-72";
const showText = !isCollapsed || isHovered;
return (
<aside
className={cn(
"fixed top-0 left-0 z-50 h-full bg-white dark:bg-gray-800",
"border-r border-gray-200 dark:border-gray-700",
"transition-all duration-300 ease-in-out",
isMobile
? cn("w-72", isOpen ? "translate-x-0" : "-translate-x-full")
: cn(sidebarWidth, "translate-x-0")
)}
onMouseEnter={!isMobile ? onMouseEnter : undefined}
onMouseLeave={!isMobile ? onMouseLeave : undefined}
>
{/* 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">
<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
</div>
</Link>
)}
{!showText && (
<div className="w-8 h-8 bg-green-600 rounded-lg flex items-center justify-center text-white text-sm font-bold mx-auto">
</div>
)}
{isMobile && (
<Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={onClose}
>
<X className="h-5 w-5" />
</Button>
)}
</div>
{/* 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}
isCollapsed={isCollapsed}
isHovered={isHovered}
/>
</div>
{/* Footer CTA - Only show when not collapsed or when hovered */}
{showText && (
<div className="p-4 mx-3 mb-6 rounded-2xl bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700">
<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
</h4>
</div>
<p className="mb-4 text-green-700 dark:text-green-200 text-xs">
Platform terpadu untuk mengelola ekosistem pengelolaan sampah yang
berkelanjutan.
</p>
<div className="flex gap-2">
<Button
size="sm"
className="flex-1 bg-green-600 hover:bg-green-700 text-white text-xs"
>
Help Center
</Button>
<Button
size="sm"
variant="outline"
className="flex-1 text-xs border-green-600 text-green-600 hover:bg-green-50 dark:border-green-500 dark:text-green-300 dark:hover:bg-green-900/20"
>
Feedback
</Button>
</div>
</div>
)}
</ScrollArea>
</aside>
);
}

View File

@ -0,0 +1,246 @@
// app/components/layoutadmin/header.tsx
import { useState } from "react";
import { Form } from "@remix-run/react";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Badge } from "~/components/ui/badge";
import { ModeToggle } from "~/components/ui/dark-mode-toggle";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "~/components/ui/dropdown-menu";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import {
Menu,
Search,
Bell,
Sun,
Moon,
User,
Settings,
LogOut,
ChevronDown,
MoreHorizontal,
PanelLeftClose,
PanelLeft
} from "lucide-react";
interface PengelolaHeaderProps {
onMenuClick: () => void;
sidebarCollapsed: boolean;
isMobile: boolean;
}
export function PengelolaHeader({
onMenuClick,
sidebarCollapsed,
isMobile
}: PengelolaHeaderProps) {
// const [isDark, setIsDark] = useState(false);
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 */}
<div className="flex items-center justify-between w-full gap-2 px-3 py-3 border-b border-gray-200 dark: sm:gap-4 lg:justify-normal lg:border-b-0 lg:px-0 lg:py-4">
{/* Hamburger menu button */}
<Button
variant="outline"
size="icon"
onClick={onMenuClick}
className="hover:bg-gray-100 dark:hover:bg-gray-700 border-gray-300 dark:border-gray-600 dark:bg-gray-800"
>
{isMobile ? (
<Menu className="h-5 w-5" />
) : sidebarCollapsed ? (
<PanelLeft className="h-5 w-5" />
) : (
<PanelLeftClose className="h-5 w-5" />
)}
</Button>
{/* 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>
</div>
{/* Mobile more menu */}
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="lg:hidden">
<MoreHorizontal className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-80">
<div className="mt-6 space-y-4">
<Button className="w-full" variant="outline">
<Bell className="mr-2 h-4 w-4" />
Notifications
<Badge variant="destructive" className="ml-auto">
3
</Badge>
</Button>
<ModeToggle />
</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 */}
<div className="hidden items-center justify-between w-full gap-4 px-5 py-4 lg:flex lg:justify-end lg:px-0">
<div className="flex items-center gap-3">
{/* Theme toggle */}
<ModeToggle />
{/* Notifications */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="relative hover:bg-gray-100 dark:hover:bg-gray-700 border-gray-300 dark:border-gray-600 dark:bg-gray-800"
>
<Bell className="h-4 w-4" />
<Badge
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
</Badge>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-80 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
>
<div className="p-4">
<h4 className="font-semibold mb-2 flex items-center justify-between text-gray-900 dark:text-gray-100">
Notifications
<Badge variant="secondary" className="text-xs">
3 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">
<p className="font-medium text-gray-900 dark:text-gray-100">
New user registered
</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-green-500">
<p className="font-medium text-gray-900 dark:text-gray-100">
System update available
</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
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* User menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-auto p-2 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage
src="https://github.com/leerob.png"
alt="User"
/>
<AvatarFallback className="bg-blue-600 text-white">
MU
</AvatarFallback>
</Avatar>
<div className="hidden sm:block text-left">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
Fahmi Kurniawan
</div>
<div className="text-xs text-gray-500 dark:text-gray-300">
Pengelola
</div>
</div>
<ChevronDown className="h-4 w-4 text-gray-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
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">Fahmi Kurniawan</div>
<div className="text-xs text-gray-500 dark:text-gray-300">
pengelola@example.com
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer">
<User className="mr-2 h-4 w-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<Settings className="mr-2 h-4 w-4" />
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"
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</button>
</Form>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,78 @@
import { useState, useEffect } from "react";
import { PengelolaSidebar } from "./sidebar";
import { PengelolaHeader } from "./header";
interface PengelolaLayoutWrapperProps {
children: React.ReactNode;
}
export function PengelolaLayoutWrapper({ children }: PengelolaLayoutWrapperProps) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 1024);
if (window.innerWidth >= 1024) {
setSidebarOpen(false);
}
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
const handleToggleSidebar = () => {
if (isMobile) {
setSidebarOpen(!sidebarOpen);
} else {
setSidebarCollapsed(!sidebarCollapsed);
}
};
const sidebarWidth = sidebarCollapsed && !isHovered ? "80px" : "290px";
const contentMargin = isMobile ? "0" : sidebarWidth;
return (
<div className="min-h-screen flex bg-gray-50 dark:bg-gray-900">
{/* Sidebar */}
<PengelolaSidebar
isOpen={sidebarOpen}
isCollapsed={sidebarCollapsed}
isHovered={isHovered}
isMobile={isMobile}
onClose={() => setSidebarOpen(false)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
/>
{/* Mobile overlay */}
{sidebarOpen && isMobile && (
<div
className="fixed inset-0 z-40 bg-gray-900/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Main content */}
<div
className="flex-1 flex flex-col transition-all duration-300 ease-in-out"
style={{
marginLeft: isMobile ? "0" : contentMargin
}}
>
<PengelolaHeader
onMenuClick={handleToggleSidebar}
sidebarCollapsed={sidebarCollapsed}
isMobile={isMobile}
/>
{/* Page content */}
<main className="flex-1 p-4 lg:p-6">{children}</main>
</div>
</div>
);
}

View File

@ -0,0 +1,621 @@
import { useState, useMemo } from "react";
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,
CollapsibleTrigger
} from "~/components/ui/collapsible";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "~/components/ui/tooltip";
import {
LayoutDashboard,
Recycle,
Users,
TrendingUp,
FileText,
CreditCard,
BarChart3,
Settings,
MessageCircle,
MapPin,
Package,
DollarSign,
UserCheck,
Building2,
Newspaper,
HelpCircle,
Bell,
Shield,
ChevronDown,
X,
Truck,
Calendar,
ClipboardList,
Target,
Timer,
AlertCircle,
CheckCircle,
Route,
Phone
} from "lucide-react";
import { cn } from "~/lib/utils";
interface SidebarProps {
isOpen: boolean;
isCollapsed: boolean;
isHovered: boolean;
isMobile: boolean;
onClose: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
interface MenuItem {
title: string;
icon?: React.ReactNode;
href?: string;
badge?: string;
badgeVariant?: "pro" | "new" | "urgent";
children?: MenuItem[];
}
const operationalMenuItems: MenuItem[] = [
{
title: "Dashboard",
icon: <LayoutDashboard className="w-5 h-5" />,
children: [
{
title: "Overview Harian",
href: "/pengelola/dashboard"
},
{
title: "Monitoring Real-time",
href: "/pengelola/dashboard/monitoring",
badge: "new"
},
{ title: "Target vs Pencapaian", href: "/pengelola/dashboard/targets" },
{ title: "Jadwal Hari Ini", href: "/pengelola/dashboard/schedule" }
]
},
{
title: "Operasional Sampah",
icon: <Recycle className="w-5 h-5" />,
children: [
{
title: "Pengumpulan Harian",
href: "/pengelola/dashboard/collection",
badge: "urgent"
},
{ title: "Sortir & Klasifikasi", href: "/pengelola/dashboard/sorting" },
{ title: "Stok Sampah", href: "/pengelola/dashboard/inventory" },
{ title: "Kualitas Check", href: "/pengelola/dashboard/quality" },
{
title: "Input Data Berat",
href: "/pengelola/dashboard/weight-input",
badge: "new"
}
]
},
{
title: "Manajemen Pengepul",
icon: <Users className="w-5 h-5" />,
children: [
{ title: "Daftar Pengepul Aktif", href: "/pengelola/collectors/active" },
{ title: "Jadwal Pickup", href: "/pengelola/collectors/schedule" },
{ title: "Performa Pengepul", href: "/pengelola/collectors/performance" },
{
title: "Pengepul Pending",
href: "/pengelola/collectors/pending",
badge: "urgent"
},
{ title: "Rating & Feedback", href: "/pengelola/collectors/feedback" }
]
},
{
title: "Transaksi & Pembayaran",
icon: <CreditCard className="w-5 h-5" />,
children: [
{ title: "Transaksi Hari Ini", href: "/pengelola/transactions/today" },
{
title: "Pembayaran Tertunda",
href: "/pengelola/transactions/pending",
badge: "urgent"
},
{
title: "Verifikasi Pembayaran",
href: "/pengelola/transactions/verification"
},
{ title: "Riwayat Transaksi", href: "/pengelola/transactions/history" },
{ title: "Rekap Harian", href: "/pengelola/transactions/daily-recap" }
]
}
];
const fieldMenuItems: MenuItem[] = [
{
title: "Area Coverage",
icon: <MapPin className="w-5 h-5" />,
children: [
{ title: "Peta Operasional", href: "/pengelola/coverage/map" },
{ title: "Rute Pengumpulan", href: "/pengelola/coverage/routes" },
{
title: "Titik Pengumpulan",
href: "/pengelola/coverage/collection-points"
},
{
title: "Area Prioritas",
href: "/pengelola/coverage/priority-areas",
badge: "new"
}
]
},
{
title: "Penjadwalan",
icon: <Calendar className="w-5 h-5" />,
children: [
{ title: "Jadwal Mingguan", href: "/pengelola/schedule/weekly" },
{ title: "Pengepul On-duty", href: "/pengelola/schedule/on-duty" },
{ title: "Shift Management", href: "/pengelola/schedule/shifts" },
{
title: "Emergency Pickup",
href: "/pengelola/schedule/emergency",
badge: "urgent"
}
]
},
{
title: "Komunikasi",
icon: <MessageCircle className="w-5 h-5" />,
children: [
{ title: "Chat Pengepul", href: "/pengelola/dashboard/chat" },
{
title: "Broadcast Message",
href: "/pengelola/dashboard/broadcast"
},
{
title: "Notifikasi Operasional",
href: "/pengelola/dashboard/notifications"
},
{
title: "Support Ticket",
href: "/pengelola/dashboard/support",
badge: "urgent"
}
]
},
{
title: "Task Management",
icon: <ClipboardList className="w-5 h-5" />,
children: [
{ title: "Task Harian", href: "/pengelola/tasks/daily", badge: "urgent" },
{ title: "Checklist Operasi", href: "/pengelola/tasks/checklist" },
{ title: "Follow-up Required", href: "/pengelola/tasks/followup" },
{ title: "Completed Tasks", href: "/pengelola/tasks/completed" }
]
}
];
const reportingMenuItems: MenuItem[] = [
{
title: "Laporan Operasional",
icon: <BarChart3 className="w-5 h-5" />,
children: [
{ title: "Laporan Harian", href: "/pengelola/reports/daily" },
{ title: "Laporan Mingguan", href: "/pengelola/reports/weekly" },
{ title: "Performa Tim", href: "/pengelola/reports/team-performance" },
{ title: "Analisis Trend", href: "/pengelola/reports/trends" }
]
},
{
title: "Monitoring Kinerja",
icon: <TrendingUp className="w-5 h-5" />,
children: [
{ title: "KPI Dashboard", href: "/pengelola/kpi/dashboard" },
{ title: "Target Achievement", href: "/pengelola/kpi/targets" },
{ title: "Efficiency Metrics", href: "/pengelola/kpi/efficiency" },
{ title: "Quality Metrics", href: "/pengelola/kpi/quality" }
]
},
{
title: "Alerts & Issues",
icon: <AlertCircle className="w-5 h-5" />,
children: [
{
title: "Alert Aktif",
href: "/pengelola/alerts/active",
badge: "urgent"
},
{ title: "Issue Tracking", href: "/pengelola/alerts/issues" },
{ title: "Maintenance Schedule", href: "/pengelola/alerts/maintenance" },
{ title: "Escalation Log", href: "/pengelola/alerts/escalation" }
]
},
{
title: "Pengaturan",
icon: <Settings className="w-5 h-5" />,
children: [
{ title: "Profil Pengelola", href: "/pengelola/settings/profile" },
{
title: "Notifikasi Setting",
href: "/pengelola/settings/notifications"
},
{ title: "Area Tanggung Jawab", href: "/pengelola/settings/area" },
{ title: "Tim & Koordinator", href: "/pengelola/settings/team" }
]
}
];
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,
isHovered
}: {
item: MenuItem;
isCollapsed: boolean;
isHovered: boolean;
}) {
const location = useLocation();
const pathname = location.pathname;
const [isOpen, setIsOpen] = useState(false);
const hasChildren = item.children && item.children.length > 0;
const showText = !isCollapsed || isHovered;
// Check if any child is active
const isChildActive = useMemo(() => {
if (!hasChildren) return false;
return item.children!.some((child) => child.href === pathname);
}, [hasChildren, item.children, pathname]);
// Auto open if child is active
useMemo(() => {
if (isChildActive && !isOpen) {
setIsOpen(true);
}
}, [isChildActive, isOpen]);
// For collapsed state without hover, show tooltip
if (isCollapsed && !isHovered) {
return (
<li>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
className={cn(
"w-full h-12 p-0 justify-center transition-colors",
isChildActive && "bg-blue-100 dark:bg-blue-900/30"
)}
asChild={!hasChildren}
>
{hasChildren ? (
<div
className={cn(
"text-gray-700 dark:text-gray-100",
isChildActive && "text-blue-700 dark:text-blue-400"
)}
>
{item.icon}
</div>
) : (
<Link to={item.href || "#"}>
<div
className={cn(
"text-gray-700 dark:text-gray-100",
pathname === item.href &&
"text-blue-700 dark:text-blue-400"
)}
>
{item.icon}
</div>
</Link>
)}
</Button>
</TooltipTrigger>
<TooltipContent side="right" className="ml-2">
<p>{item.title}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</li>
);
}
if (hasChildren) {
return (
<li>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className={cn(
"w-full justify-between px-3 py-2.5 h-auto transition-colors",
"hover:bg-gray-100 dark:hover:bg-gray-700",
isChildActive &&
"bg-blue-50 text-blue-700 hover:bg-blue-100 dark:bg-blue-900/20 dark:text-blue-400 dark:hover:bg-blue-900/30"
)}
>
<div className="flex items-center gap-3">
<span
className={cn(
"transition-colors",
isChildActive
? "text-blue-600 dark:text-blue-400"
: "text-gray-700 dark:text-gray-100"
)}
>
{item.icon}
</span>
{showText && (
<span
className={cn(
"text-sm font-medium text-gray-900 dark:text-gray-100",
isChildActive && "text-blue-700 dark:text-blue-400"
)}
>
{item.title}
</span>
)}
</div>
{showText && (
<ChevronDown
className={cn(
"h-4 w-4 transition-transform",
isOpen && "rotate-180",
isChildActive && "text-blue-600 dark:text-blue-400"
)}
/>
)}
</Button>
</CollapsibleTrigger>
{showText && (
<CollapsibleContent className="ml-8 mt-1 space-y-1">
{item.children?.map((child, childIndex) => {
const isActive = pathname === child.href;
return (
<Link
key={childIndex}
to={child.href || "#"}
className={cn(
"flex items-center justify-between px-3 py-2 text-sm rounded-md transition-colors",
"hover:bg-gray-100 dark:hover:bg-gray-700",
isActive
? "bg-blue-100 text-blue-700 font-medium hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-400 dark:hover:bg-blue-900/50"
: "text-gray-600 dark:text-gray-200"
)}
>
<span className="flex items-center gap-2">
{isActive && (
<div className="w-1 h-4 bg-blue-600 rounded-full" />
)}
{child.title}
</span>
{child.badge && (
<Badge
variant={
child.badge === "urgent"
? "destructive"
: child.badge === "pro"
? "secondary"
: "default"
}
className={cn(
"text-xs",
child.badge === "urgent" &&
"bg-red-100 text-red-700 hover:bg-red-200",
child.badge === "new" &&
"bg-green-100 text-green-700 hover:bg-green-200"
)}
>
{child.badge}
</Badge>
)}
</Link>
);
})}
</CollapsibleContent>
)}
</Collapsible>
</li>
);
}
// Single menu item (no children)
const isActive = pathname === item.href;
return (
<li>
<Button
asChild
variant="ghost"
className={cn(
"w-full justify-start px-3 py-2.5 h-auto transition-colors",
"hover:bg-gray-100 dark:hover:bg-gray-700",
isActive &&
"bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-400 dark:hover:bg-blue-900/50"
)}
>
<Link to={item.href || "#"}>
<div className="flex items-center gap-3">
<span
className={cn(
"transition-colors",
isActive
? "text-blue-600 dark:text-blue-400"
: "text-gray-700 dark:text-gray-100"
)}
>
{item.icon}
</span>
{showText && (
<span
className={cn(
"text-sm font-medium text-gray-900 dark:text-gray-100",
isActive && "text-blue-700 dark:text-blue-400"
)}
>
{item.title}
</span>
)}
</div>
</Link>
</Button>
</li>
);
}
export function PengelolaSidebar({
isOpen,
isCollapsed,
isHovered,
isMobile,
onClose,
onMouseEnter,
onMouseLeave
}: SidebarProps) {
const sidebarWidth = isCollapsed && !isHovered ? "w-20" : "w-72";
const showText = !isCollapsed || isHovered;
return (
<aside
className={cn(
"fixed top-0 left-0 z-50 h-full bg-white dark:bg-gray-800",
"border-r border-gray-200 dark:border-gray-700",
"transition-all duration-300 ease-in-out",
isMobile
? cn("w-72", isOpen ? "translate-x-0" : "-translate-x-full")
: cn(sidebarWidth, "translate-x-0")
)}
onMouseEnter={!isMobile ? onMouseEnter : undefined}
onMouseLeave={!isMobile ? onMouseLeave : undefined}
>
{/* Logo */}
<div className="h-16 flex items-center justify-between px-4 border-b border-gray-200 dark:border-gray-700">
{showText && (
<Link to="/pengelola/dashboard" className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-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 Pengelola
</div>
</Link>
)}
{!showText && (
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white text-sm font-bold mx-auto">
</div>
)}
{isMobile && (
<Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={onClose}
>
<X className="h-5 w-5" />
</Button>
)}
</div>
{/* Navigation */}
<ScrollArea className="flex-1 h-[calc(100vh-64px)]">
<div className="py-6 px-3 space-y-6">
<MenuSection
title="Operasional Harian"
items={operationalMenuItems}
isCollapsed={isCollapsed}
isHovered={isHovered}
/>
<MenuSection
title="Field Operations"
items={fieldMenuItems}
isCollapsed={isCollapsed}
isHovered={isHovered}
/>
<MenuSection
title="Reporting & Settings"
items={reportingMenuItems}
isCollapsed={isCollapsed}
isHovered={isHovered}
/>
</div>
{/* Footer CTA - Only show when not collapsed or when hovered */}
{showText && (
<div className="p-4 mx-3 mb-6 rounded-2xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700">
<div className="flex items-center gap-2 mb-2">
<Truck className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<h4 className="font-semibold text-blue-900 dark:text-blue-100 text-sm">
Pengelola Dashboard
</h4>
</div>
<p className="mb-4 text-blue-700 dark:text-blue-200 text-xs">
Kelola operasional harian pengumpulan dan pengolahan sampah secara
efisien.
</p>
<div className="flex gap-2">
<Button
size="sm"
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white text-xs"
>
Emergency Call
</Button>
<Button
size="sm"
variant="outline"
className="flex-1 text-xs border-blue-600 text-blue-600 hover:bg-blue-50 dark:border-blue-500 dark:text-blue-300 dark:hover:bg-blue-900/20"
>
Quick Report
</Button>
</div>
</div>
)}
</ScrollArea>
</aside>
);
}

View File

@ -0,0 +1,192 @@
import { useEffect, useState } from "react";
import { MapPin } from "lucide-react";
interface Location {
id: string;
name: string;
type: string;
address: string;
coordinates: [number, number];
status: string;
capacity: number;
currentLoad: number;
lastPickup: string;
coverage: string;
population: number;
schedule: string;
}
interface MapComponentProps {
locations: Location[];
onLocationSelect: (location: Location) => void;
}
export function LeafletMap({ locations, onLocationSelect }: MapComponentProps) {
const [mapComponents, setMapComponents] = useState<any>(null);
const [leaflet, setLeaflet] = useState<any>(null);
useEffect(() => {
const loadMapComponents = async () => {
try {
const [{ MapContainer, TileLayer, Marker, Popup }, L] =
await Promise.all([import("react-leaflet"), import("leaflet")]);
delete (L as any).Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl:
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png",
iconUrl:
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png",
shadowUrl:
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png"
});
setMapComponents({ MapContainer, TileLayer, Marker, Popup });
setLeaflet(L);
} catch (error) {
console.error("Error loading map components:", error);
}
};
loadMapComponents();
}, []);
// Custom icon function
const getMarkerIcon = (type: string, status: string) => {
if (!leaflet) return undefined;
const color =
status === "active"
? "#10b981"
: status === "maintenance"
? "#f59e0b"
: "#ef4444";
return leaflet.divIcon({
html: `
<div style="
background-color: ${color};
width: 24px;
height: 24px;
border-radius: 50%;
border: 3px solid white;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
position: relative;
">
<div style="
width: 8px;
height: 8px;
background-color: white;
border-radius: 50%;
"></div>
</div>
`,
className: "custom-div-icon",
iconSize: [24, 24],
iconAnchor: [12, 12],
popupAnchor: [0, -12]
});
};
if (!mapComponents || !leaflet) {
return (
<div className="h-[600px] w-full bg-muted rounded-lg flex items-center justify-center">
<div className="text-center">
<MapPin className="h-12 w-12 mx-auto mb-4 text-muted-foreground animate-pulse" />
<p className="text-muted-foreground">Loading map...</p>
</div>
</div>
);
}
const { MapContainer, TileLayer, Marker, Popup } = mapComponents;
return (
<MapContainer
center={[-6.1944, 106.8229]} // Jakarta Pusat
zoom={13}
scrollWheelZoom={true}
style={{ height: "600px", width: "100%" }}
className="rounded-lg z-0"
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{locations.map((location) => (
<Marker
key={location.id}
position={[location.coordinates[0], location.coordinates[1]]}
icon={getMarkerIcon(location.type, location.status)}
eventHandlers={{
click: () => onLocationSelect(location)
}}
>
<Popup>
<div className="p-2 min-w-[250px]">
<h3 className="font-semibold text-sm mb-1">{location.name}</h3>
<p className="text-xs text-gray-600 mb-3">{location.address}</p>
<div className="space-y-2">
<div className="flex justify-between text-xs">
<span className="font-medium">Status:</span>
<span
className={`px-2 py-1 rounded-full text-xs ${
location.status === "active"
? "bg-green-100 text-green-800"
: "bg-orange-100 text-orange-800"
}`}
>
{location.status}
</span>
</div>
<div className="flex justify-between text-xs">
<span className="font-medium">Load:</span>
<span>
{Math.round(
(location.currentLoad / location.capacity) * 100
)}
%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all"
style={{
width: `${
(location.currentLoad / location.capacity) * 100
}%`
}}
></div>
</div>
<div className="flex justify-between text-xs">
<span className="font-medium">Population:</span>
<span>{location.population.toLocaleString()}</span>
</div>
<div className="flex justify-between text-xs">
<span className="font-medium">Last Pickup:</span>
<span>{location.lastPickup}</span>
</div>
</div>
<button
className="w-full mt-3 px-3 py-1.5 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition-colors"
onClick={() => onLocationSelect(location)}
>
View Details
</button>
</div>
</Popup>
</Marker>
))}
</MapContainer>
);
}

View File

@ -0,0 +1,155 @@
"use client";
import { cn } from "~/lib/utils";
import React, {
createContext,
useState,
useContext,
useRef,
useEffect,
} from "react";
const MouseEnterContext = createContext<
[boolean, React.Dispatch<React.SetStateAction<boolean>>] | undefined
>(undefined);
export const CardContainer = ({
children,
className,
containerClassName,
}: {
children?: React.ReactNode;
className?: string;
containerClassName?: string;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [isMouseEntered, setIsMouseEntered] = useState(false);
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!containerRef.current) return;
const { left, top, width, height } =
containerRef.current.getBoundingClientRect();
const x = (e.clientX - left - width / 2) / 25;
const y = (e.clientY - top - height / 2) / 25;
containerRef.current.style.transform = `rotateY(${x}deg) rotateX(${y}deg)`;
};
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
setIsMouseEntered(true);
if (!containerRef.current) return;
};
const handleMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
if (!containerRef.current) return;
setIsMouseEntered(false);
containerRef.current.style.transform = `rotateY(0deg) rotateX(0deg)`;
};
return (
<MouseEnterContext.Provider value={[isMouseEntered, setIsMouseEntered]}>
<div
className={cn(
"py-20 flex items-center justify-center",
containerClassName
)}
style={{
perspective: "1000px",
}}
>
<div
ref={containerRef}
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className={cn(
"flex items-center justify-center relative transition-all duration-200 ease-linear",
className
)}
style={{
transformStyle: "preserve-3d",
}}
>
{children}
</div>
</div>
</MouseEnterContext.Provider>
);
};
export const CardBody = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => {
return (
<div
className={cn(
"h-96 w-96 [transform-style:preserve-3d] [&>*]:[transform-style:preserve-3d]",
className
)}
>
{children}
</div>
);
};
export const CardItem = ({
as: Tag = "div",
children,
className,
translateX = 0,
translateY = 0,
translateZ = 0,
rotateX = 0,
rotateY = 0,
rotateZ = 0,
...rest
}: {
as?: React.ElementType;
children: React.ReactNode;
className?: string;
translateX?: number | string;
translateY?: number | string;
translateZ?: number | string;
rotateX?: number | string;
rotateY?: number | string;
rotateZ?: number | string;
[key: string]: any;
}) => {
const ref = useRef<HTMLDivElement>(null);
const [isMouseEntered] = useMouseEnter();
useEffect(() => {
handleAnimations();
}, [isMouseEntered]);
const handleAnimations = () => {
if (!ref.current) return;
if (isMouseEntered) {
ref.current.style.transform = `translateX(${translateX}px) translateY(${translateY}px) translateZ(${translateZ}px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) rotateZ(${rotateZ}deg)`;
} else {
ref.current.style.transform = `translateX(0px) translateY(0px) translateZ(0px) rotateX(0deg) rotateY(0deg) rotateZ(0deg)`;
}
};
return (
<Tag
ref={ref}
className={cn("w-fit transition duration-200 ease-linear", className)}
{...rest}
>
{children}
</Tag>
);
};
// Create a hook to use the context
export const useMouseEnter = () => {
const context = useContext(MouseEnterContext);
if (context === undefined) {
throw new Error("useMouseEnter must be used within a MouseEnterProvider");
}
return context;
};

View File

@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "~/lib/utils"
import { buttonVariants } from "~/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,48 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "~/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "~/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@ -0,0 +1,34 @@
import { Moon, Sun } from "lucide-react";
import { Theme, useTheme } from "remix-themes";
import { Button } from "./button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "./dropdown-menu";
export function ModeToggle() {
const [, setTheme] = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-9 w-9">
<Sun className="h-[1.1rem] w-[1.1rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.1rem] w-[1.1rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme(Theme.LIGHT)}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme(Theme.DARK)}>
Dark
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "~/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,199 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "~/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
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 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 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden 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 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "~/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,710 @@
// app/components/ui/macbook-scroll.tsx
import React, { useEffect, useRef, useState } from "react";
import { MotionValue, motion, useScroll, useTransform } from "motion/react";
import { cn } from "~/lib/utils";
import {
Monitor,
MonitorSpeaker,
Sun,
Moon,
Search,
Mic,
SkipBack,
Play,
SkipForward,
Volume,
Volume1,
Volume2,
VolumeX,
Globe,
Command,
ChevronUp,
ChevronLeft,
ChevronRight,
ChevronDown,
Grid3x3
} from "lucide-react";
interface MacbookScrollProps {
src?: string;
showGradient?: boolean;
title?: string | React.ReactNode;
badge?: React.ReactNode;
className?: string;
// Target landing - best practice approach
landingTargetId?: string;
landingOffset?: number;
}
export const MacbookScroll = ({
src,
showGradient = true,
title,
badge,
className,
landingTargetId,
landingOffset = 0
}: MacbookScrollProps) => {
const ref = useRef<HTMLDivElement>(null);
const [landingDistance, setLandingDistance] = useState(800);
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start start", "end start"],
});
// Responsive landing calculation - best practice
useEffect(() => {
const calculateLanding = () => {
if (!landingTargetId) return;
const target = document.getElementById(landingTargetId);
const container = ref.current;
if (target && container) {
const containerRect = container.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
// Calculate absolute positions
const containerTop = containerRect.top + scrollTop;
const targetTop = targetRect.top + scrollTop;
// Distance from container to target
const distance = targetTop - containerTop + landingOffset;
// Mobile responsive adjustment - more aggressive
const isMobile = window.innerWidth < 1024; // Changed from 768 to 1024
const finalDistance = isMobile ? distance * 2.5 : distance; // More reduction for mobile
setLandingDistance(Math.max(finalDistance, 200));
}
};
calculateLanding();
const handleResize = () => calculateLanding();
const handleLoad = () => calculateLanding();
window.addEventListener('resize', handleResize);
window.addEventListener('load', handleLoad);
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('load', handleLoad);
};
}, [landingTargetId, landingOffset]);
// Simple transforms
const scaleX = useTransform(scrollYProgress, [0, 0.3], [1.2, 1.5]);
const scaleY = useTransform(scrollYProgress, [0, 0.3], [0.6, 1.5]);
const translate = useTransform(scrollYProgress, [0, 1], [0, landingDistance]);
const rotate = useTransform(scrollYProgress, [0.1, 0.12, 0.3], [-28, -28, 0]);
const textTransform = useTransform(scrollYProgress, [0, 0.3], [0, 100]);
const textOpacity = useTransform(scrollYProgress, [0, 0.2], [1, 0]);
return (
<div
ref={ref}
data-macbook-container
className={cn(
// Mobile-first: Height dan scale yang balance
"flex shrink-0 transform flex-col items-center justify-start [perspective:800px]",
// Height: JAUH lebih pendek di mobile
"min-h-[80vh] lg:min-h-[200vh]",
// Scale: Balance - tidak terlalu kecil, tidak terlalu besar
"scale-50 md:scale-75 lg:scale-100",
// Padding: MINIMAL untuk mobile
"py-4 lg:py-20",
className
)}
>
{/* Menggunakan CSS variables untuk responsive - best practice Remix */}
<style>{`
@media (max-width: 1023px) {
[data-macbook-container] {
min-height: 70vh !important;
max-height: 70vh !important;
padding-top: 0.5rem !important;
padding-bottom: 0.5rem !important;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
`}</style>
<motion.h2
style={{
translateY: textTransform,
opacity: textOpacity,
}}
className="mb-2 lg:mb-16 text-center text-sm md:text-lg lg:text-3xl font-bold text-neutral-800 dark:text-white px-4"
>
{title || (
<span>
This Macbook is built with Tailwindcss. <br /> No kidding.
</span>
)}
</motion.h2>
{/* Lid */}
<Lid
src={src}
scaleX={scaleX}
scaleY={scaleY}
rotate={rotate}
translate={translate}
/>
{/* Base - responsive sizing */}
<div className="relative -z-10 h-[16rem] w-[24rem] md:h-[20rem] md:w-[28rem] lg:h-[22rem] lg:w-[32rem] overflow-hidden rounded-2xl bg-gray-200 dark:bg-[#272729]">
{/* Keyboard bar */}
<div className="relative h-6 md:h-8 lg:h-10 w-full">
<div className="absolute inset-x-0 mx-auto h-2 md:h-3 lg:h-4 w-[80%] bg-[#050505]" />
</div>
<div className="relative flex">
<div className="mx-auto h-full w-[10%] overflow-hidden">
<SpeakerGrid />
</div>
<div className="mx-auto h-full w-[80%]">
<Keypad />
</div>
<div className="mx-auto h-full w-[10%] overflow-hidden">
<SpeakerGrid />
</div>
</div>
<Trackpad />
<div className="absolute inset-x-0 bottom-0 mx-auto h-1.5 md:h-2 w-12 md:w-16 lg:w-20 rounded-tl-3xl rounded-tr-3xl bg-gradient-to-t from-[#272729] to-[#050505]" />
{showGradient && (
<div className="absolute inset-x-0 bottom-0 z-50 h-24 md:h-32 lg:h-40 w-full bg-gradient-to-t from-white via-white to-transparent dark:from-black dark:via-black"></div>
)}
{badge && <div className="absolute bottom-2 md:bottom-3 lg:bottom-4 left-2 md:left-3 lg:left-4">{badge}</div>}
</div>
</div>
);
};
export const Lid = ({
scaleX,
scaleY,
rotate,
translate,
src,
}: {
scaleX: MotionValue<number>;
scaleY: MotionValue<number>;
rotate: MotionValue<number>;
translate: MotionValue<number>;
src?: string;
}) => {
return (
<div className="relative [perspective:800px]">
<div
style={{
transform: "perspective(800px) rotateX(-25deg) translateZ(0px)",
transformOrigin: "bottom",
transformStyle: "preserve-3d",
}}
className="relative h-[8rem] w-[24rem] md:h-[10rem] md:w-[28rem] lg:h-[12rem] lg:w-[32rem] rounded-2xl bg-[#010101] p-2"
>
<div
style={{
boxShadow: "0px 2px 0px 2px #171717 inset",
}}
className="absolute inset-0 flex items-center justify-center rounded-lg bg-[#010101]"
>
</div>
</div>
<motion.div
style={{
scaleX: scaleX,
scaleY: scaleY,
rotateX: rotate,
translateY: translate,
transformStyle: "preserve-3d",
transformOrigin: "top",
zIndex: 50, // Ensure it stays on top
}}
className="absolute inset-0 h-64 md:h-72 lg:h-96 w-[24rem] md:w-[28rem] lg:w-[32rem] rounded-2xl bg-[#010101] p-2 z-50"
>
<div className="absolute inset-0 rounded-lg bg-[#272729]" />
{src && (
<img
src={src}
alt="screen content"
className="absolute inset-0 h-full w-full rounded-lg object-cover object-left-top"
loading="lazy"
/>
)}
</motion.div>
</div>
);
};
export const Trackpad = () => {
return (
<div
className="mx-auto my-1 h-32 w-[40%] rounded-xl"
style={{
boxShadow: "0px 0px 1px 1px #00000020 inset",
}}
></div>
);
};
export const Keypad = () => {
return (
<div className="mx-1 h-full [transform:translateZ(0)] rounded-md bg-[#050505] p-1 [will-change:transform]">
{/* First Row */}
<div className="mb-[2px] flex w-full shrink-0 gap-[2px]">
<KBtn
className="w-10 items-end justify-start pb-[2px] pl-[4px]"
childrenClassName="items-start"
>
esc
</KBtn>
<KBtn>
<Monitor className="h-[6px] w-[6px]" />
<span className="mt-1 inline-block">F1</span>
</KBtn>
<KBtn>
<Sun className="h-[6px] w-[6px]" />
<span className="mt-1 inline-block">F2</span>
</KBtn>
<KBtn>
<Grid3x3 className="h-[6px] w-[6px]" />
<span className="mt-1 inline-block">F3</span>
</KBtn>
<KBtn>
<Search className="h-[6px] w-[6px]" />
<span className="mt-1 inline-block">F4</span>
</KBtn>
<KBtn>
<Mic className="h-[6px] w-[6px]" />
<span className="mt-1 inline-block">F5</span>
</KBtn>
<KBtn>
<Moon className="h-[6px] w-[6px]" />
<span className="mt-1 inline-block">F6</span>
</KBtn>
<KBtn>
<SkipBack className="h-[6px] w-[6px]" />
<span className="mt-1 inline-block">F7</span>
</KBtn>
<KBtn>
<Play className="h-[6px] w-[6px]" />
<span className="mt-1 inline-block">F8</span>
</KBtn>
<KBtn>
<SkipForward className="h-[6px] w-[6px]" />
<span className="mt-1 inline-block">F9</span>
</KBtn>
<KBtn>
<VolumeX className="h-[6px] w-[6px]" />
<span className="mt-1 inline-block">F10</span>
</KBtn>
<KBtn>
<Volume1 className="h-[6px] w-[6px]" />
<span className="mt-1 inline-block">F11</span>
</KBtn>
<KBtn>
<Volume2 className="h-[6px] w-[6px]" />
<span className="mt-1 inline-block">F12</span>
</KBtn>
<KBtn>
<div className="h-4 w-4 rounded-full bg-gradient-to-b from-neutral-900 from-20% via-black via-50% to-neutral-900 to-95% p-px">
<div className="h-full w-full rounded-full bg-black" />
</div>
</KBtn>
</div>
{/* Second row */}
<div className="mb-[2px] flex w-full shrink-0 gap-[2px]">
<KBtn>
<span className="block">~</span>
<span className="mt-1 block">`</span>
</KBtn>
<KBtn>
<span className="block">!</span>
<span className="block">1</span>
</KBtn>
<KBtn>
<span className="block">@</span>
<span className="block">2</span>
</KBtn>
<KBtn>
<span className="block">#</span>
<span className="block">3</span>
</KBtn>
<KBtn>
<span className="block">$</span>
<span className="block">4</span>
</KBtn>
<KBtn>
<span className="block">%</span>
<span className="block">5</span>
</KBtn>
<KBtn>
<span className="block">^</span>
<span className="block">6</span>
</KBtn>
<KBtn>
<span className="block">&</span>
<span className="block">7</span>
</KBtn>
<KBtn>
<span className="block">*</span>
<span className="block">8</span>
</KBtn>
<KBtn>
<span className="block">(</span>
<span className="block">9</span>
</KBtn>
<KBtn>
<span className="block">)</span>
<span className="block">0</span>
</KBtn>
<KBtn>
<span className="block">_</span>
<span className="block">-</span>
</KBtn>
<KBtn>
<span className="block">+</span>
<span className="block">=</span>
</KBtn>
<KBtn
className="w-10 items-end justify-end pr-[4px] pb-[2px]"
childrenClassName="items-end"
>
delete
</KBtn>
</div>
{/* Third row */}
<div className="mb-[2px] flex w-full shrink-0 gap-[2px]">
<KBtn
className="w-10 items-end justify-start pb-[2px] pl-[4px]"
childrenClassName="items-start"
>
tab
</KBtn>
<KBtn>
<span className="block">Q</span>
</KBtn>
<KBtn>
<span className="block">W</span>
</KBtn>
<KBtn>
<span className="block">E</span>
</KBtn>
<KBtn>
<span className="block">R</span>
</KBtn>
<KBtn>
<span className="block">T</span>
</KBtn>
<KBtn>
<span className="block">Y</span>
</KBtn>
<KBtn>
<span className="block">U</span>
</KBtn>
<KBtn>
<span className="block">I</span>
</KBtn>
<KBtn>
<span className="block">O</span>
</KBtn>
<KBtn>
<span className="block">P</span>
</KBtn>
<KBtn>
<span className="block">{`{`}</span>
<span className="block">{`[`}</span>
</KBtn>
<KBtn>
<span className="block">{`}`}</span>
<span className="block">{`]`}</span>
</KBtn>
<KBtn>
<span className="block">{`|`}</span>
<span className="block">{`\\`}</span>
</KBtn>
</div>
{/* Fourth Row */}
<div className="mb-[2px] flex w-full shrink-0 gap-[2px]">
<KBtn
className="w-[2.8rem] items-end justify-start pb-[2px] pl-[4px]"
childrenClassName="items-start"
>
caps lock
</KBtn>
<KBtn>
<span className="block">A</span>
</KBtn>
<KBtn>
<span className="block">S</span>
</KBtn>
<KBtn>
<span className="block">D</span>
</KBtn>
<KBtn>
<span className="block">F</span>
</KBtn>
<KBtn>
<span className="block">G</span>
</KBtn>
<KBtn>
<span className="block">H</span>
</KBtn>
<KBtn>
<span className="block">J</span>
</KBtn>
<KBtn>
<span className="block">K</span>
</KBtn>
<KBtn>
<span className="block">L</span>
</KBtn>
<KBtn>
<span className="block">{`:`}</span>
<span className="block">{`;`}</span>
</KBtn>
<KBtn>
<span className="block">{`"`}</span>
<span className="block">{`'`}</span>
</KBtn>
<KBtn
className="w-[2.85rem] items-end justify-end pr-[4px] pb-[2px]"
childrenClassName="items-end"
>
return
</KBtn>
</div>
{/* Fifth Row */}
<div className="mb-[2px] flex w-full shrink-0 gap-[2px]">
<KBtn
className="w-[3.65rem] items-end justify-start pb-[2px] pl-[4px]"
childrenClassName="items-start"
>
shift
</KBtn>
<KBtn>
<span className="block">Z</span>
</KBtn>
<KBtn>
<span className="block">X</span>
</KBtn>
<KBtn>
<span className="block">C</span>
</KBtn>
<KBtn>
<span className="block">V</span>
</KBtn>
<KBtn>
<span className="block">B</span>
</KBtn>
<KBtn>
<span className="block">N</span>
</KBtn>
<KBtn>
<span className="block">M</span>
</KBtn>
<KBtn>
<span className="block">{`<`}</span>
<span className="block">{`,`}</span>
</KBtn>
<KBtn>
<span className="block">{`>`}</span>
<span className="block">{`.`}</span>
</KBtn>
<KBtn>
<span className="block">{`?`}</span>
<span className="block">{`/`}</span>
</KBtn>
<KBtn
className="w-[3.65rem] items-end justify-end pr-[4px] pb-[2px]"
childrenClassName="items-end"
>
shift
</KBtn>
</div>
{/* Sixth Row */}
<div className="mb-[2px] flex w-full shrink-0 gap-[2px]">
<KBtn className="" childrenClassName="h-full justify-between py-[4px]">
<div className="flex w-full justify-end pr-1">
<span className="block">fn</span>
</div>
<div className="flex w-full justify-start pl-1">
<Globe className="h-[6px] w-[6px]" />
</div>
</KBtn>
<KBtn className="" childrenClassName="h-full justify-between py-[4px]">
<div className="flex w-full justify-end pr-1">
<ChevronUp className="h-[6px] w-[6px]" />
</div>
<div className="flex w-full justify-start pl-1">
<span className="block">control</span>
</div>
</KBtn>
<KBtn className="" childrenClassName="h-full justify-between py-[4px]">
<div className="flex w-full justify-end pr-1">
<OptionKey className="h-[6px] w-[6px]" />
</div>
<div className="flex w-full justify-start pl-1">
<span className="block">option</span>
</div>
</KBtn>
<KBtn
className="w-8"
childrenClassName="h-full justify-between py-[4px]"
>
<div className="flex w-full justify-end pr-1">
<Command className="h-[6px] w-[6px]" />
</div>
<div className="flex w-full justify-start pl-1">
<span className="block">command</span>
</div>
</KBtn>
<KBtn className="w-[8.2rem]"></KBtn>
<KBtn
className="w-8"
childrenClassName="h-full justify-between py-[4px]"
>
<div className="flex w-full justify-start pl-1">
<Command className="h-[6px] w-[6px]" />
</div>
<div className="flex w-full justify-start pl-1">
<span className="block">command</span>
</div>
</KBtn>
<KBtn className="" childrenClassName="h-full justify-between py-[4px]">
<div className="flex w-full justify-start pl-1">
<OptionKey className="h-[6px] w-[6px]" />
</div>
<div className="flex w-full justify-start pl-1">
<span className="block">option</span>
</div>
</KBtn>
<div className="mt-[2px] flex h-6 w-[4.9rem] flex-col items-center justify-end rounded-[4px] p-[0.5px]">
<KBtn className="h-3 w-6">
<ChevronUp className="h-[6px] w-[6px]" />
</KBtn>
<div className="flex">
<KBtn className="h-3 w-6">
<ChevronLeft className="h-[6px] w-[6px]" />
</KBtn>
<KBtn className="h-3 w-6">
<ChevronDown className="h-[6px] w-[6px]" />
</KBtn>
<KBtn className="h-3 w-6">
<ChevronRight className="h-[6px] w-[6px]" />
</KBtn>
</div>
</div>
</div>
</div>
);
};
export const KBtn = ({
className,
children,
childrenClassName,
backlit = true,
}: {
className?: string;
children?: React.ReactNode;
childrenClassName?: string;
backlit?: boolean;
}) => {
return (
<div
className={cn(
"[transform:translateZ(0)] rounded-[4px] p-[0.5px] [will-change:transform]",
backlit && "bg-white/[0.2] shadow-xl shadow-white",
)}
>
<div
className={cn(
"flex h-6 w-6 items-center justify-center rounded-[3.5px] bg-[#0A090D]",
className,
)}
style={{
boxShadow:
"0px -0.5px 2px 0 #0D0D0F inset, -0.5px 0px 2px 0 #0D0D0F inset",
}}
>
<div
className={cn(
"flex w-full flex-col items-center justify-center text-[5px] text-neutral-200",
childrenClassName,
backlit && "text-white",
)}
>
{children}
</div>
</div>
</div>
);
};
export const SpeakerGrid = () => {
return (
<div
className="mt-2 flex h-40 gap-[2px] px-[0.5px]"
style={{
backgroundImage:
"radial-gradient(circle, #08080A 0.5px, transparent 0.5px)",
backgroundSize: "3px 3px",
}}
></div>
);
};
export const OptionKey = ({ className }: { className: string }) => {
return (
<svg
fill="none"
version="1.1"
id="icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
className={className}
>
<rect
stroke="currentColor"
strokeWidth={2}
x="18"
y="5"
width="10"
height="2"
/>
<polygon
stroke="currentColor"
strokeWidth={2}
points="10.6,5 4,5 4,7 9.4,7 18.4,27 28,27 28,25 19.6,25 "
/>
<rect
id="_Transparent_Rectangle_"
className="st0"
width="32"
height="32"
stroke="none"
/>
</svg>
);
};

View File

@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "~/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "~/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,157 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "~/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden 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 origin-[--radix-select-content-transform-origin]",
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)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "~/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

138
app/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,138 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "~/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
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",
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",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "~/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
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 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

120
app/components/ui/table.tsx Normal file
View File

@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "~/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "~/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "~/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -0,0 +1,30 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "~/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground 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 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

6
app/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -1,11 +1,21 @@
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData
} from "@remix-run/react";
import type { LinksFunction } from "@remix-run/node";
import clsx from "clsx";
import {
PreventFlashOnWrongTheme,
ThemeProvider,
useTheme
} from "remix-themes";
import { themeSessionResolver } from "./sessions.server";
import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
import { ProgressProvider } from "@bprogress/remix";
import "./tailwind.css";
@ -14,32 +24,54 @@ export const links: LinksFunction = () => [
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
crossOrigin: "anonymous"
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
},
{
rel: "stylesheet",
href: "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
}
];
export function Layout({ children }: { children: React.ReactNode }) {
export async function loader({ request }: LoaderFunctionArgs) {
const { getTheme } = await themeSessionResolver(request);
return {
theme: getTheme()
};
}
export default function AppWithProviders() {
const data = useLoaderData<typeof loader>();
return (
<html lang="en">
<ThemeProvider specifiedTheme={data.theme} themeAction="/action/set-theme">
<App />
</ThemeProvider>
);
}
export function App() {
const data = useLoaderData<typeof loader>();
const [theme] = useTheme();
return (
<html lang="id" className={clsx(theme)}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
<ProgressProvider startOnLoad>
<ScrollRestoration />
<Scripts />
<LiveReload />
<Outlet />
</ProgressProvider>
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}

View File

@ -1,138 +0,0 @@
import type { MetaFunction } from "@remix-run/node";
export const meta: MetaFunction = () => {
return [
{ title: "New Remix App" },
{ name: "description", content: "Welcome to Remix!" },
];
};
export default function Index() {
return (
<div className="flex h-screen items-center justify-center">
<div className="flex flex-col items-center gap-16">
<header className="flex flex-col items-center gap-9">
<h1 className="leading text-2xl font-bold text-gray-800 dark:text-gray-100">
Welcome to <span className="sr-only">Remix</span>
</h1>
<div className="h-[144px] w-[434px]">
<img
src="/logo-light.png"
alt="Remix"
className="block w-full dark:hidden"
/>
<img
src="/logo-dark.png"
alt="Remix"
className="hidden w-full dark:block"
/>
</div>
</header>
<nav className="flex flex-col items-center justify-center gap-4 rounded-3xl border border-gray-200 p-6 dark:border-gray-700">
<p className="leading-6 text-gray-700 dark:text-gray-200">
What&apos;s next?
</p>
<ul>
{resources.map(({ href, text, icon }) => (
<li key={href}>
<a
className="group flex items-center gap-3 self-stretch p-3 leading-normal text-blue-700 hover:underline dark:text-blue-500"
href={href}
target="_blank"
rel="noreferrer"
>
{icon}
{text}
</a>
</li>
))}
</ul>
</nav>
</div>
</div>
);
}
const resources = [
{
href: "https://remix.run/start/quickstart",
text: "Quick Start (5 min)",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="20"
viewBox="0 0 20 20"
fill="none"
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
>
<path
d="M8.51851 12.0741L7.92592 18L15.6296 9.7037L11.4815 7.33333L12.0741 2L4.37036 10.2963L8.51851 12.0741Z"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
},
{
href: "https://remix.run/start/tutorial",
text: "Tutorial (30 min)",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="20"
viewBox="0 0 20 20"
fill="none"
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
>
<path
d="M4.561 12.749L3.15503 14.1549M3.00811 8.99944H1.01978M3.15503 3.84489L4.561 5.2508M8.3107 1.70923L8.3107 3.69749M13.4655 3.84489L12.0595 5.2508M18.1868 17.0974L16.635 18.6491C16.4636 18.8205 16.1858 18.8205 16.0144 18.6491L13.568 16.2028C13.383 16.0178 13.0784 16.0347 12.915 16.239L11.2697 18.2956C11.047 18.5739 10.6029 18.4847 10.505 18.142L7.85215 8.85711C7.75756 8.52603 8.06365 8.21994 8.39472 8.31453L17.6796 10.9673C18.0223 11.0653 18.1115 11.5094 17.8332 11.7321L15.7766 13.3773C15.5723 13.5408 15.5554 13.8454 15.7404 14.0304L18.1868 16.4767C18.3582 16.6481 18.3582 16.926 18.1868 17.0974Z"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
},
{
href: "https://remix.run/docs",
text: "Remix Docs",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="20"
viewBox="0 0 20 20"
fill="none"
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
>
<path
d="M9.99981 10.0751V9.99992M17.4688 17.4688C15.889 19.0485 11.2645 16.9853 7.13958 12.8604C3.01467 8.73546 0.951405 4.11091 2.53116 2.53116C4.11091 0.951405 8.73546 3.01467 12.8604 7.13958C16.9853 11.2645 19.0485 15.889 17.4688 17.4688ZM2.53132 17.4688C0.951566 15.8891 3.01483 11.2645 7.13974 7.13963C11.2647 3.01471 15.8892 0.951453 17.469 2.53121C19.0487 4.11096 16.9854 8.73551 12.8605 12.8604C8.73562 16.9853 4.11107 19.0486 2.53132 17.4688Z"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
),
},
{
href: "https://rmx.as/discord",
text: "Join Discord",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="20"
viewBox="0 0 24 20"
fill="none"
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
>
<path
d="M15.0686 1.25995L14.5477 1.17423L14.2913 1.63578C14.1754 1.84439 14.0545 2.08275 13.9422 2.31963C12.6461 2.16488 11.3406 2.16505 10.0445 2.32014C9.92822 2.08178 9.80478 1.84975 9.67412 1.62413L9.41449 1.17584L8.90333 1.25995C7.33547 1.51794 5.80717 1.99419 4.37748 2.66939L4.19 2.75793L4.07461 2.93019C1.23864 7.16437 0.46302 11.3053 0.838165 15.3924L0.868838 15.7266L1.13844 15.9264C2.81818 17.1714 4.68053 18.1233 6.68582 18.719L7.18892 18.8684L7.50166 18.4469C7.96179 17.8268 8.36504 17.1824 8.709 16.4944L8.71099 16.4904C10.8645 17.0471 13.128 17.0485 15.2821 16.4947C15.6261 17.1826 16.0293 17.8269 16.4892 18.4469L16.805 18.8725L17.3116 18.717C19.3056 18.105 21.1876 17.1751 22.8559 15.9238L23.1224 15.724L23.1528 15.3923C23.5873 10.6524 22.3579 6.53306 19.8947 2.90714L19.7759 2.73227L19.5833 2.64518C18.1437 1.99439 16.6386 1.51826 15.0686 1.25995ZM16.6074 10.7755L16.6074 10.7756C16.5934 11.6409 16.0212 12.1444 15.4783 12.1444C14.9297 12.1444 14.3493 11.6173 14.3493 10.7877C14.3493 9.94885 14.9378 9.41192 15.4783 9.41192C16.0471 9.41192 16.6209 9.93851 16.6074 10.7755ZM8.49373 12.1444C7.94513 12.1444 7.36471 11.6173 7.36471 10.7877C7.36471 9.94885 7.95323 9.41192 8.49373 9.41192C9.06038 9.41192 9.63892 9.93712 9.6417 10.7815C9.62517 11.6239 9.05462 12.1444 8.49373 12.1444Z"
strokeWidth="1.5"
/>
</svg>
),
},
];

File diff suppressed because it is too large Load Diff

405
app/routes/_landing.tsx Normal file
View File

@ -0,0 +1,405 @@
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Outlet, useLoaderData, useNavigate } from "@remix-run/react";
import { useState, useEffect } from "react";
import { Button } from "~/components/ui/button";
import { Separator } from "~/components/ui/separator";
import {
Menu,
X,
Facebook,
Twitter,
Linkedin,
Instagram,
Recycle,
ArrowUp
} from "lucide-react";
import { ModeToggle } from "~/components/ui/dark-mode-toggle";
export const meta: MetaFunction = () => {
return [
{ title: "Rijig - Platform Pengelolaan Sampah Terpadu" },
{
name: "description",
content:
"Platform pengelolaan sampah terpadu yang menghubungkan masyarakat, pengepul, dan pengelola untuk ekonomi sirkular berkelanjutan"
},
{ name: "viewport", content: "width=device-width, initial-scale=1" }
];
};
export async function loader({ request }: LoaderFunctionArgs) {
const navigationItems = [
{ name: "Fitur", href: "#features" },
{ name: "Tentang", href: "#about" },
{ name: "Cara Kerja", href: "#work-process" },
{ name: "Testimoni", href: "#testimonials" },
{ name: "Kontak", href: "#support" }
];
const socialLinks = [
{ name: "Facebook", icon: "facebook", href: "#" },
{ name: "Twitter", icon: "twitter", href: "#" },
{ name: "LinkedIn", icon: "linkedin", href: "#" },
{ name: "Instagram", icon: "instagram", href: "#" }
];
const authData = {
isAuthenticated: false,
isRegistrationComplete: false,
userRole: null as string | null
};
return json({
navigationItems,
socialLinks,
authData
});
}
export default function LandingLayout() {
const { navigationItems, socialLinks, authData } =
useLoaderData<typeof loader>();
const navigate = useNavigate();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const [showBackToTop, setShowBackToTop] = useState(false);
const { isAuthenticated, isRegistrationComplete, userRole } = authData;
useEffect(() => {
const handleScroll = () => {
const scrollTop = window.scrollY;
setIsScrolled(scrollTop > 20);
setShowBackToTop(scrollTop > 300);
};
handleScroll();
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: "smooth"
});
};
const toggleMenu = () => setIsMenuOpen(!isMenuOpen);
const handleNavClick = (href: string) => {
setIsMenuOpen(false);
if (href.startsWith("#")) {
const element = document.querySelector(href);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
}
}
};
const getRedirectPath = () => {
if (userRole === "administrator") {
return "/sys-rijig-adminpanel/dashboard";
}
return "/pengelola/dashboard";
};
const handleGetStarted = () => {
if (isAuthenticated && isRegistrationComplete) {
const dashboardPath =
userRole === "administrator"
? "/sys-rijig-adminpanel/dashboard"
: "/pengelola/dashboard";
navigate(dashboardPath);
} else if (isAuthenticated && !isRegistrationComplete) {
const redirectPath = getRedirectPath();
navigate(redirectPath);
} else {
navigate("/pengelola/register");
}
};
const handleAdminLogin = () => {
navigate("/sys-rijig-adminpanel/login");
};
const getButtonText = () => {
if (isAuthenticated && isRegistrationComplete) {
return "Go to Dashboard";
}
return "Get Started";
};
return (
<div className="min-h-screen">
{/* Header dengan glassmorphism effect */}
<header
className={`fixed top-0 left-0 z-50 w-full transition-all duration-700 ease-out ${
isScrolled
? "bg-white/80 backdrop-blur-xl dark:bg-gray-900/80 border-b border-white/20 dark:border-gray-700/30 shadow-2xl shadow-black/10 dark:shadow-black/20"
: "bg-transparent backdrop-blur-none border-transparent shadow-none"
}`}
>
<div className="container mx-auto max-w-[1400px] px-4">
<div className="flex items-center justify-between py-4">
{/* Logo */}
<div className="flex items-center space-x-2">
<div
className={`w-8 h-8 rounded-lg flex items-center justify-center transition-all duration-700 ${
isScrolled
? "bg-green-600 shadow-lg"
: "bg-white/20 dark:bg-gray-800/20 backdrop-blur-sm border border-white/30 dark:border-gray-700/30"
}`}
>
<Recycle
className={`h-5 w-5 transition-all duration-700 ${
isScrolled
? "text-white"
: "text-green-600 dark:text-green-400 drop-shadow-md"
}`}
/>
</div>
<div
className={`text-2xl font-bold transition-all duration-700 ${
isScrolled
? "text-black dark:text-white"
: "text-white dark:text-white drop-shadow-lg"
}`}
>
Rijig
</div>
</div>
{/* Mobile Menu Button */}
<Button
variant="ghost"
size="icon"
className={`lg:hidden transition-all duration-700 ${
isScrolled
? "text-black dark:text-white hover:bg-gray-100/50 dark:hover:bg-gray-700/50"
: "text-white dark:text-white hover:bg-white/20 dark:hover:bg-gray-800/20 backdrop-blur-sm"
}`}
onClick={toggleMenu}
aria-label="Toggle menu"
>
{isMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
{/* Desktop Navigation - Centered */}
<nav className="hidden lg:flex items-center justify-center flex-1 mx-8">
<div className="flex items-center space-x-8">
{navigationItems.map((item) => (
<button
key={item.href}
onClick={() => handleNavClick(item.href)}
className={`text-base font-medium transition-all duration-700 hover:scale-105 ${
isScrolled
? "text-black dark:text-white hover:text-green-600 dark:hover:text-green-400"
: "text-white dark:text-white hover:text-green-200 dark:hover:text-green-300 drop-shadow-lg hover:drop-shadow-xl"
}`}
>
{item.name}
</button>
))}
</div>
</nav>
{/* Right Side Actions */}
<div className="hidden lg:flex items-center space-x-4">
<div
className={`transition-all duration-700 ${
isScrolled
? "text-black dark:text-white"
: "text-white dark:text-white"
}`}
>
<ModeToggle />
</div>
<Button
onClick={handleGetStarted}
className={`transition-all duration-700 shadow-lg hover:scale-105 rounded-lg ${
isScrolled
? "bg-green-600 hover:bg-green-700 text-white shadow-green-600/20"
: "bg-green-600/90 hover:bg-green-700/90 text-white backdrop-blur-sm border border-green-500/30 shadow-green-600/30"
}`}
>
{getButtonText()}
</Button>
</div>
</div>
{/* Mobile Menu */}
<div
className={`lg:hidden transition-all duration-300 ease-in-out ${
isMenuOpen
? "max-h-96 opacity-100 pb-4"
: "max-h-0 opacity-0 overflow-hidden"
}`}
>
<div
className={`rounded-xl mt-2 transition-all duration-700 ${
isScrolled
? "bg-white/90 dark:bg-gray-900/90 backdrop-blur-xl border border-white/20 dark:border-gray-700/30 shadow-2xl"
: "bg-white/10 dark:bg-gray-900/10 backdrop-blur-lg border border-white/20 dark:border-gray-700/20"
}`}
>
<nav className="space-y-4 p-4">
{navigationItems.map((item) => (
<button
key={item.href}
onClick={() => handleNavClick(item.href)}
className={`block w-full text-left text-base font-medium transition-all duration-700 hover:scale-105 ${
isScrolled
? "text-black dark:text-white hover:text-green-600"
: "text-white dark:text-white hover:text-green-200"
}`}
>
{item.name}
</button>
))}
<Separator
className={`my-4 ${
isScrolled
? "bg-gray-200/60 dark:bg-gray-700/60"
: "bg-white/40 dark:bg-gray-700/40"
}`}
/>
{/* Mobile Actions */}
<div className="flex items-center justify-between">
<div
className={`transition-all duration-700 ${
isScrolled
? "text-black dark:text-white"
: "text-white dark:text-white"
}`}
>
<ModeToggle />
</div>
<Button
onClick={handleGetStarted}
size="sm"
className={`hover:scale-105 transition-all duration-700 rounded-lg ${
isScrolled
? "bg-green-600 hover:bg-green-700 text-white"
: "bg-green-600/90 hover:bg-green-700/90 text-white backdrop-blur-sm border border-green-500/30"
}`}
>
{getButtonText()}
</Button>
</div>
</nav>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main>
<Outlet />
</main>
{/* Back to Top Button */}
<Button
onClick={scrollToTop}
className={`fixed bottom-6 right-6 z-40 w-12 h-12 rounded-full bg-green-600 hover:bg-green-700 text-white shadow-lg transition-all duration-300 hover:scale-110 ${
showBackToTop
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-2 pointer-events-none"
}`}
aria-label="Back to top"
>
<ArrowUp className="h-5 w-5" />
</Button>
{/* Footer */}
<footer className="bg-green-50 dark:bg-gray-800">
<div className="container mx-auto max-w-[1390px] px-4 py-8">
<div className="grid lg:grid-cols-12 gap-8">
<div className="lg:col-span-5">
<div className="mb-8">
<div className="flex items-center space-x-2 mb-4">
<div className="w-8 h-8 bg-green-600 rounded-lg flex items-center justify-center">
<Recycle className="h-5 w-5 text-white" />
</div>
<div className="text-2xl font-bold text-black dark:text-white">
Rijig
</div>
</div>
<p className="text-gray-600 dark:text-gray-300 max-w-[320px]">
Platform pengelolaan sampah terpadu yang menghubungkan
masyarakat, pengepul, dan pengelola untuk ekonomi sirkular
berkelanjutan.
</p>
</div>
</div>
</div>
</div>
<div className="bg-green-600 dark:bg-green-700 py-6">
<div className="container mx-auto max-w-[1390px] px-4">
<div className="flex flex-col md:flex-row justify-between items-center">
<p className="text-white text-center md:text-left mb-4 md:mb-0">
© 2025 Rijig - layout inspired by appline
</p>
<div className="flex items-center space-x-6">
<div className="flex items-center space-x-4">
<Button
variant="ghost"
size="icon"
className="text-white hover:text-white/80 hover:bg-green-700 transition-all hover:scale-110 rounded-lg"
aria-label="Facebook"
>
<Facebook className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-white hover:text-white/80 hover:bg-green-700 transition-all hover:scale-110 rounded-lg"
aria-label="Twitter"
>
<Twitter className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-white hover:text-white/80 hover:bg-green-700 transition-all hover:scale-110 rounded-lg"
aria-label="LinkedIn"
>
<Linkedin className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-white hover:text-white/80 hover:bg-green-700 transition-all hover:scale-110 rounded-lg"
aria-label="Instagram"
>
<Instagram className="h-5 w-5" />
</Button>
</div>
<div className="flex items-center space-x-6 text-white text-sm">
<button className="hover:text-white/80 transition-colors hover:scale-105">
Kebijakan Privasi
</button>
<button className="hover:text-white/80 transition-colors hover:scale-105">
Syarat & Ketentuan
</button>
</div>
</div>
</div>
</div>
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,4 @@
import { createThemeAction } from "remix-themes"
import { themeSessionResolver } from "../sessions.server"
export const action = createThemeAction(themeSessionResolver)

View File

@ -0,0 +1,6 @@
import { redirect } from "@remix-run/node";
export const loader = async () => {
return redirect("/pengelola/dashboard");
};

View File

@ -0,0 +1,509 @@
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Progress } from "~/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import {
Truck,
Users,
MapPin,
TrendingUp,
TrendingDown,
Clock,
CheckCircle,
AlertTriangle,
Calendar,
BarChart3,
Recycle,
Package
} from "lucide-react";
// Interface untuk activity
interface Activity {
id: number;
type: string;
title: string;
time: string;
status: string;
driver?: string;
volume?: string;
estimatedComplete?: string;
estimatedVolume?: string;
reason?: string;
}
interface Truck {
id: string;
driver: string;
status: string;
location: string;
capacity: string;
lastUpdate: string;
}
interface WeeklyProgress {
day: string;
target: number;
actual: number;
}
interface DashboardData {
stats: {
totalSampahHariIni: number;
targetHarian: number;
trukAktif: number;
totalTruk: number;
pegawaiAktif: number;
totalPegawai: number;
wilayahTerlayani: number;
totalWilayah: number;
};
recentActivities: Activity[];
trucks: Truck[];
weeklyProgress: WeeklyProgress[];
}
// Loader untuk mengambil data dashboard
export const loader = async (): Promise<Response> => {
// Mock data - dalam implementasi nyata, ambil dari database
const dashboardData: DashboardData = {
stats: {
totalSampahHariIni: 2450, // kg
targetHarian: 3000, // kg
trukAktif: 8,
totalTruk: 12,
pegawaiAktif: 24,
totalPegawai: 30,
wilayahTerlayani: 15,
totalWilayah: 18
},
recentActivities: [
{
id: 1,
type: "pickup",
title: "Pengangkutan selesai di Kelurahan Merdeka",
time: "10 menit yang lalu",
status: "completed",
driver: "Budi Santoso",
volume: "245 kg"
},
{
id: 2,
type: "maintenance",
title: "Truk B-002 menjalani maintenance rutin",
time: "1 jam yang lalu",
status: "in-progress",
estimatedComplete: "14:00"
},
{
id: 3,
type: "pickup",
title: "Pengangkutan dimulai di Komplek Permata",
time: "2 jam yang lalu",
status: "in-progress",
driver: "Andi Wijaya",
estimatedVolume: "180 kg"
},
{
id: 4,
type: "alert",
title: "Jadwal pengangkutan tertunda di Jl. Sudirman",
time: "3 jam yang lalu",
status: "warning",
reason: "Kemacetan lalu lintas"
}
],
trucks: [
{
id: "B-001",
driver: "Budi Santoso",
status: "active",
location: "Kelurahan Merdeka",
capacity: "85%",
lastUpdate: "5 menit yang lalu"
},
{
id: "B-002",
driver: "Andi Wijaya",
status: "maintenance",
location: "Workshop",
capacity: "0%",
lastUpdate: "1 jam yang lalu"
},
{
id: "B-003",
driver: "Sari Dewi",
status: "active",
location: "Komplek Permata",
capacity: "60%",
lastUpdate: "15 menit yang lalu"
},
{
id: "B-004",
driver: "Dedi Kurniawan",
status: "idle",
location: "Pool Kendaraan",
capacity: "0%",
lastUpdate: "2 jam yang lalu"
}
],
weeklyProgress: [
{ day: "Sen", target: 3000, actual: 2800 },
{ day: "Sel", target: 3000, actual: 3200 },
{ day: "Rab", target: 3000, actual: 2950 },
{ day: "Kam", target: 3000, actual: 3100 },
{ day: "Jum", target: 3000, actual: 2750 },
{ day: "Sab", target: 2500, actual: 2450 },
{ day: "Min", target: 2000, actual: 0 } // hari ini
]
};
return json(dashboardData);
};
export default function PengelolaDashboard() {
const data = useLoaderData<DashboardData>();
const getStatusBadge = (status: string) => {
switch (status) {
case "completed":
return (
<Badge variant="default" className="bg-green-100 text-green-800">
Selesai
</Badge>
);
case "in-progress":
return (
<Badge variant="default" className="bg-blue-100 text-blue-800">
Berlangsung
</Badge>
);
case "warning":
return <Badge variant="destructive">Peringatan</Badge>;
case "active":
return (
<Badge variant="default" className="bg-green-100 text-green-800">
Aktif
</Badge>
);
case "maintenance":
return (
<Badge variant="default" className="bg-yellow-100 text-yellow-800">
Maintenance
</Badge>
);
case "idle":
return <Badge variant="secondary">Standby</Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
};
const getActivityIcon = (type: string) => {
switch (type) {
case "pickup":
return <Truck className="h-4 w-4" />;
case "maintenance":
return <AlertTriangle className="h-4 w-4" />;
case "alert":
return <Clock className="h-4 w-4" />;
default:
return <Package className="h-4 w-4" />;
}
};
const progressPercentage = Math.round(
(data.stats.totalSampahHariIni / data.stats.targetHarian) * 100
);
return (
<div className="flex-1 space-y-4 p-4 pt-6">
{/* Header Dashboard */}
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">
Dashboard Pengelola
</h2>
<div className="flex items-center space-x-2">
<Button>
<Calendar className="mr-2 h-4 w-4" />
Jadwal Hari Ini
</Button>
<Button variant="outline">
<BarChart3 className="mr-2 h-4 w-4" />
Laporan
</Button>
</div>
</div>
{/* Cards Statistik Utama */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Sampah Terkumpul Hari Ini
</CardTitle>
<Recycle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{data.stats.totalSampahHariIni.toLocaleString()} kg
</div>
<Progress value={progressPercentage} className="mt-2" />
<p className="text-xs text-muted-foreground mt-2">
{progressPercentage}% dari target (
{data.stats.targetHarian.toLocaleString()} kg)
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Truk Operasional
</CardTitle>
<Truck className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{data.stats.trukAktif}/{data.stats.totalTruk}
</div>
<Progress
value={(data.stats.trukAktif / data.stats.totalTruk) * 100}
className="mt-2"
/>
<p className="text-xs text-muted-foreground mt-2">
{Math.round((data.stats.trukAktif / data.stats.totalTruk) * 100)}%
truk sedang beroperasi
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Pegawai Aktif</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{data.stats.pegawaiAktif}/{data.stats.totalPegawai}
</div>
<Progress
value={(data.stats.pegawaiAktif / data.stats.totalPegawai) * 100}
className="mt-2"
/>
<p className="text-xs text-muted-foreground mt-2">
{Math.round(
(data.stats.pegawaiAktif / data.stats.totalPegawai) * 100
)}
% pegawai sedang bertugas
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Wilayah Terlayani
</CardTitle>
<MapPin className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{data.stats.wilayahTerlayani}/{data.stats.totalWilayah}
</div>
<Progress
value={
(data.stats.wilayahTerlayani / data.stats.totalWilayah) * 100
}
className="mt-2"
/>
<p className="text-xs text-muted-foreground mt-2">
{Math.round(
(data.stats.wilayahTerlayani / data.stats.totalWilayah) * 100
)}
% wilayah tercover hari ini
</p>
</CardContent>
</Card>
</div>
{/* Tabs untuk konten detail */}
<Tabs defaultValue="activities" className="space-y-4">
<TabsList>
<TabsTrigger value="activities">Aktivitas Terbaru</TabsTrigger>
<TabsTrigger value="trucks">Status Truk</TabsTrigger>
<TabsTrigger value="performance">Performa Mingguan</TabsTrigger>
</TabsList>
<TabsContent value="activities" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Aktivitas Terbaru</CardTitle>
<CardDescription>
Pantau semua aktivitas operasional secara real-time
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{data.recentActivities.map((activity) => (
<div
key={activity.id}
className="flex items-center space-x-4 border-b pb-4 last:border-b-0"
>
<div className="flex-shrink-0">
{getActivityIcon(activity.type)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">
{activity.title}
</p>
<div className="flex items-center space-x-2 mt-1">
<p className="text-xs text-gray-500">{activity.time}</p>
{activity.driver && (
<span className="text-xs text-gray-500">
Driver: {activity.driver}
</span>
)}
{activity.volume && (
<span className="text-xs text-gray-500">
{activity.volume}
</span>
)}
{activity.estimatedVolume && (
<span className="text-xs text-gray-500">
Est: {activity.estimatedVolume}
</span>
)}
{activity.estimatedComplete && (
<span className="text-xs text-gray-500">
Selesai: {activity.estimatedComplete}
</span>
)}
{activity.reason && (
<span className="text-xs text-gray-500">
{activity.reason}
</span>
)}
</div>
</div>
<div className="flex-shrink-0">
{getStatusBadge(activity.status)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="trucks" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Status Truk Real-time</CardTitle>
<CardDescription>
Monitor kondisi dan lokasi semua armada truk
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{data.trucks.map((truck) => (
<div
key={truck.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex items-center space-x-4">
<div className="flex-shrink-0">
<Truck className="h-8 w-8 text-gray-400" />
</div>
<div>
<h4 className="text-sm font-medium">{truck.id}</h4>
<p className="text-sm text-gray-500">
Driver: {truck.driver}
</p>
<p className="text-xs text-gray-400">
Update: {truck.lastUpdate}
</p>
</div>
</div>
<div className="text-right">
<div className="flex items-center space-x-2 mb-1">
{getStatusBadge(truck.status)}
</div>
<p className="text-sm text-gray-600">{truck.location}</p>
<p className="text-xs text-gray-500">
Kapasitas: {truck.capacity}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="performance" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Performa Mingguan</CardTitle>
<CardDescription>
Pencapaian target pengumpulan sampah minggu ini
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{data.weeklyProgress.map((day, index) => (
<div
key={day.day}
className="flex items-center justify-between p-3 border rounded"
>
<div className="flex items-center space-x-3">
<span className="text-sm font-medium w-8">{day.day}</span>
<div className="flex-1">
<div className="flex items-center space-x-2">
<span className="text-sm">
Target: {day.target.toLocaleString()} kg
</span>
<span className="text-sm">
Actual: {day.actual.toLocaleString()} kg
</span>
</div>
<Progress
value={
day.actual > 0 ? (day.actual / day.target) * 100 : 0
}
className="mt-1 h-2"
/>
</div>
</div>
<div className="flex items-center space-x-2">
{day.actual >= day.target ? (
<TrendingUp className="h-4 w-4 text-green-500" />
) : day.actual > 0 ? (
<TrendingDown className="h-4 w-4 text-yellow-500" />
) : (
<Clock className="h-4 w-4 text-gray-400" />
)}
<span className="text-sm font-medium">
{day.actual > 0
? `${Math.round((day.actual / day.target) * 100)}%`
: "-"}
</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,596 @@
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { useState, useRef, useEffect } from "react";
import { Card, CardContent, CardHeader } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Badge } from "~/components/ui/badge";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import { Separator } from "~/components/ui/separator";
import { Textarea } from "~/components/ui/textarea";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "~/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from "~/components/ui/dialog";
import {
Search,
Send,
Phone,
Video,
MoreVertical,
Paperclip,
Image,
MapPin,
Clock,
Check,
CheckCheck,
User,
MessageCircle,
Truck,
Package,
AlertCircle
} from "lucide-react";
// Interfaces
interface Message {
id: string;
senderId: string;
senderName: string;
content: string;
timestamp: string;
type: "text" | "image" | "location" | "document";
status: "sent" | "delivered" | "read";
attachmentUrl?: string;
}
interface Contact {
id: string;
name: string;
role: string;
avatar?: string;
isOnline: boolean;
lastSeen: string;
lastMessage: string;
unreadCount: number;
location?: string;
phone: string;
}
interface ChatData {
contacts: Contact[];
messages: { [contactId: string]: Message[] };
currentUser: {
id: string;
name: string;
};
}
export const loader = async (): Promise<Response> => {
// Mock data - dalam implementasi nyata, ambil dari database
const chatData: ChatData = {
currentUser: {
id: "pengelola-001",
name: "Ahmad Pengelola"
},
contacts: [
{
id: "pengepul-001",
name: "Budi Santoso",
role: "Driver Truk B-001",
avatar: "",
isOnline: true,
lastSeen: "Online",
lastMessage: "Sampah sudah diangkut semua pak",
unreadCount: 2,
location: "Kelurahan Merdeka",
phone: "081234567890"
},
{
id: "pengepul-002",
name: "Sari Dewi",
role: "Driver Truk B-003",
avatar: "",
isOnline: false,
lastSeen: "15 menit yang lalu",
lastMessage: "Baik pak, segera ke lokasi",
unreadCount: 0,
location: "Komplek Permata",
phone: "081234567891"
},
{
id: "pengepul-003",
name: "Dedi Kurniawan",
role: "Driver Truk B-004",
avatar: "",
isOnline: true,
lastSeen: "Online",
lastMessage: "Truk sedang dalam perjalanan",
unreadCount: 1,
location: "Jl. Sudirman",
phone: "081234567892"
},
{
id: "pengepul-004",
name: "Andi Wijaya",
role: "Driver Truk B-002",
avatar: "",
isOnline: false,
lastSeen: "2 jam yang lalu",
lastMessage: "Maintenance selesai besok pagi",
unreadCount: 0,
location: "Workshop",
phone: "081234567893"
},
{
id: "pengepul-005",
name: "Rini Astuti",
role: "Supervisor Lapangan",
avatar: "",
isOnline: true,
lastSeen: "Online",
lastMessage: "Laporan harian sudah dikirim",
unreadCount: 0,
location: "Pool Kendaraan",
phone: "081234567894"
}
],
messages: {
"pengepul-001": [
{
id: "msg-001",
senderId: "pengepul-001",
senderName: "Budi Santoso",
content:
"Selamat pagi pak, saya sudah sampai di lokasi Kelurahan Merdeka",
timestamp: "08:00",
type: "text",
status: "read"
},
{
id: "msg-002",
senderId: "pengelola-001",
senderName: "Ahmad Pengelola",
content: "Pagi Bud, bagus. Berapa estimasi waktu pengangkutan?",
timestamp: "08:02",
type: "text",
status: "read"
},
{
id: "msg-003",
senderId: "pengepul-001",
senderName: "Budi Santoso",
content: "Sekitar 2 jam pak, volume sampah cukup banyak hari ini",
timestamp: "08:05",
type: "text",
status: "read"
},
{
id: "msg-004",
senderId: "pengepul-001",
senderName: "Budi Santoso",
content: "Sampah sudah diangkut semua pak",
timestamp: "10:30",
type: "text",
status: "delivered"
},
{
id: "msg-005",
senderId: "pengepul-001",
senderName: "Budi Santoso",
content: "Total 245 kg, lanjut ke lokasi berikutnya?",
timestamp: "10:31",
type: "text",
status: "sent"
}
],
"pengepul-002": [
{
id: "msg-006",
senderId: "pengelola-001",
senderName: "Ahmad Pengelola",
content: "Sari, bisa ke Komplek Permata sekarang?",
timestamp: "09:15",
type: "text",
status: "read"
},
{
id: "msg-007",
senderId: "pengepul-002",
senderName: "Sari Dewi",
content: "Baik pak, segera ke lokasi",
timestamp: "09:17",
type: "text",
status: "read"
}
],
"pengepul-003": [
{
id: "msg-008",
senderId: "pengepul-003",
senderName: "Dedi Kurniawan",
content:
"Pak, ada kemacetan di Jl. Sudirman, mungkin terlambat 30 menit",
timestamp: "11:00",
type: "text",
status: "read"
},
{
id: "msg-009",
senderId: "pengelola-001",
senderName: "Ahmad Pengelola",
content: "OK Ded, hati-hati di jalan. Update terus ya",
timestamp: "11:05",
type: "text",
status: "read"
},
{
id: "msg-010",
senderId: "pengepul-003",
senderName: "Dedi Kurniawan",
content: "Truk sedang dalam perjalanan",
timestamp: "11:45",
type: "text",
status: "delivered"
}
]
}
};
return json(chatData);
};
export default function ChatPengepul() {
const data = useLoaderData<ChatData>();
const [selectedContact, setSelectedContact] = useState<Contact | null>(
data.contacts[0]
);
const [messageInput, setMessageInput] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const scrollAreaRef = useRef<HTMLDivElement>(null);
// Filter contacts berdasarkan search
const filteredContacts = data.contacts.filter(
(contact) =>
contact.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
contact.role.toLowerCase().includes(searchQuery.toLowerCase())
);
// Get messages untuk contact yang dipilih
const currentMessages = selectedContact
? data.messages[selectedContact.id] || []
: [];
// Auto scroll ke pesan terbaru
useEffect(() => {
if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight;
}
}, [currentMessages]);
const handleSendMessage = () => {
if (!messageInput.trim() || !selectedContact) return;
// Implementasi kirim pesan (dalam real app, hit API)
console.log("Sending message:", messageInput, "to:", selectedContact.name);
setMessageInput("");
};
const getStatusIcon = (status: string) => {
switch (status) {
case "sent":
return <Check className="h-3 w-3 text-gray-400" />;
case "delivered":
return <CheckCheck className="h-3 w-3 text-gray-400" />;
case "read":
return <CheckCheck className="h-3 w-3 text-blue-500" />;
default:
return <Clock className="h-3 w-3 text-gray-400" />;
}
};
const getContactStatusBadge = (contact: Contact) => {
if (contact.isOnline) {
return (
<Badge
variant="default"
className="bg-green-100 text-green-800 text-xs"
>
Online
</Badge>
);
}
return (
<Badge variant="secondary" className="text-xs">
{contact.lastSeen}
</Badge>
);
};
return (
<div className="flex-1 space-y-4 p-4 pt-6">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">Chat Pengepul</h2>
<div className="flex items-center space-x-2">
<Button variant="outline" size="sm">
<Phone className="mr-2 h-4 w-4" />
Panggil Semua
</Button>
<Button variant="outline" size="sm">
<MessageCircle className="mr-2 h-4 w-4" />
Broadcast
</Button>
</div>
</div>
{/* Chat Container */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 h-[calc(100vh-200px)]">
{/* Sidebar Contact List */}
<Card className="lg:col-span-1">
<CardHeader className="pb-3">
<div className="flex items-center space-x-2">
<Search className="h-4 w-4 text-gray-400" />
<Input
placeholder="Cari pengepul..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="border-none shadow-none focus-visible:ring-0"
/>
</div>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="h-[calc(100vh-300px)]">
<div className="space-y-1 p-2">
{filteredContacts.map((contact) => (
<div
key={contact.id}
onClick={() => setSelectedContact(contact)}
className={`p-3 rounded-lg cursor-pointer transition-colors ${
selectedContact?.id === contact.id
? "bg-primary/10 border border-primary/20"
: "hover:bg-gray-50"
}`}
>
<div className="flex items-center space-x-3">
<div className="relative">
<Avatar className="h-10 w-10">
<AvatarImage src={contact.avatar} />
<AvatarFallback>
{contact.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
{contact.isOnline && (
<div className="absolute bottom-0 right-0 h-3 w-3 bg-green-500 rounded-full border-2 border-white"></div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className="text-sm font-medium truncate">
{contact.name}
</p>
{contact.unreadCount > 0 && (
<Badge
variant="destructive"
className="h-5 w-5 p-0 text-xs flex items-center justify-center"
>
{contact.unreadCount}
</Badge>
)}
</div>
<div className="flex items-center space-x-1 mt-1">
<Truck className="h-3 w-3 text-gray-400" />
<p className="text-xs text-gray-500 truncate">
{contact.role}
</p>
</div>
<p className="text-xs text-gray-500 truncate mt-1">
{contact.lastMessage}
</p>
<div className="flex items-center justify-between mt-2">
<div className="flex items-center space-x-1">
<MapPin className="h-3 w-3 text-gray-400" />
<span className="text-xs text-gray-500">
{contact.location}
</span>
</div>
{getContactStatusBadge(contact)}
</div>
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
{/* Chat Area */}
<Card className="lg:col-span-3 flex flex-col">
{selectedContact ? (
<>
{/* Chat Header */}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<div className="flex items-center space-x-3">
<Avatar className="h-10 w-10">
<AvatarImage src={selectedContact.avatar} />
<AvatarFallback>
{selectedContact.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div>
<h3 className="font-semibold">{selectedContact.name}</h3>
<div className="flex items-center space-x-2">
<p className="text-sm text-gray-500">
{selectedContact.role}
</p>
{getContactStatusBadge(selectedContact)}
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<Button variant="outline" size="sm">
<Phone className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm">
<Video className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<User className="mr-2 h-4 w-4" />
Lihat Profil
</DropdownMenuItem>
<DropdownMenuItem>
<MapPin className="mr-2 h-4 w-4" />
Lacak Lokasi
</DropdownMenuItem>
<DropdownMenuItem>
<Package className="mr-2 h-4 w-4" />
Riwayat Tugas
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<Separator />
{/* Messages Area */}
<CardContent className="flex-1 p-4">
<ScrollArea
className="h-[calc(100vh-420px)]"
ref={scrollAreaRef}
>
<div className="space-y-4">
{currentMessages.map((message) => {
const isOwnMessage =
message.senderId === data.currentUser.id;
return (
<div
key={message.id}
className={`flex ${
isOwnMessage ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-[70%] rounded-lg p-3 ${
isOwnMessage
? "bg-primary text-primary-foreground"
: "bg-gray-100 text-gray-900"
}`}
>
<p className="text-sm">{message.content}</p>
<div
className={`flex items-center justify-between mt-2 ${
isOwnMessage
? "text-primary-foreground/70"
: "text-gray-500"
}`}
>
<span className="text-xs">
{message.timestamp}
</span>
{isOwnMessage && (
<div className="ml-2">
{getStatusIcon(message.status)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</ScrollArea>
</CardContent>
{/* Message Input */}
<div className="p-4 border-t">
<div className="flex items-center space-x-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Paperclip className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Image className="mr-2 h-4 w-4" />
Kirim Foto
</DropdownMenuItem>
<DropdownMenuItem>
<MapPin className="mr-2 h-4 w-4" />
Bagikan Lokasi
</DropdownMenuItem>
<DropdownMenuItem>
<Package className="mr-2 h-4 w-4" />
Kirim Dokumen
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex-1 flex space-x-2">
<Textarea
placeholder="Ketik pesan..."
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
className="min-h-[40px] max-h-[120px] resize-none"
onKeyPress={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
/>
<Button
onClick={handleSendMessage}
size="sm"
className="px-3"
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</>
) : (
// Empty State
<CardContent className="flex-1 flex items-center justify-center">
<div className="text-center">
<MessageCircle className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Pilih Chat
</h3>
<p className="text-gray-500">
Pilih pengepul untuk mulai berkomunikasi
</p>
</div>
</CardContent>
)}
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,984 @@
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { useState, useEffect } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Progress } from "~/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "~/components/ui/select";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Separator } from "~/components/ui/separator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from "~/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from "~/components/ui/alert-dialog";
import {
Truck,
MapPin,
Clock,
CheckCircle,
AlertTriangle,
XCircle,
RotateCcw,
Users,
Target,
TrendingUp,
Calendar,
RefreshCw,
Phone,
MessageSquare,
Plus,
Edit,
Trash2,
Navigation,
Package,
Timer,
Zap
} from "lucide-react";
// Interfaces
interface CollectionArea {
id: string;
name: string;
zone: string;
priority: "high" | "medium" | "low";
status: "pending" | "in-progress" | "completed" | "overdue" | "cancelled";
scheduledTime: string;
estimatedDuration: number; // minutes
actualStartTime?: string;
completedTime?: string;
assignedTruck: string;
assignedDriver: string;
driverContact: string;
estimatedVolume: number; // kg
actualVolume?: number; // kg
households: number;
lastCollection: string;
notes?: string;
coordinates: { lat: number; lng: number };
urgentIssues?: string[];
}
interface DailyStats {
totalAreas: number;
completedAreas: number;
inProgressAreas: number;
overdueAreas: number;
totalTargetVolume: number;
collectedVolume: number;
activeTrucks: number;
availableTrucks: number;
estimatedCompletion: string;
}
interface TruckStatus {
id: string;
driver: string;
contact: string;
status: "active" | "available" | "maintenance" | "break";
currentLocation?: string;
currentCapacity: number; // percentage
assignedAreas: string[];
lastUpdate: string;
}
interface CollectionData {
date: string;
stats: DailyStats;
areas: CollectionArea[];
trucks: TruckStatus[];
emergencyContacts: {
supervisor: string;
dispatcher: string;
maintenance: string;
};
}
export const loader = async (): Promise<Response> => {
// Mock data - dalam implementasi nyata, ambil dari database
const today = new Date().toISOString().split("T")[0];
const collectionData: CollectionData = {
date: today,
stats: {
totalAreas: 18,
completedAreas: 12,
inProgressAreas: 4,
overdueAreas: 2,
totalTargetVolume: 4500,
collectedVolume: 3200,
activeTrucks: 6,
availableTrucks: 2,
estimatedCompletion: "16:30"
},
areas: [
{
id: "area-001",
name: "Kelurahan Merdeka Blok A",
zone: "Zona Utara",
priority: "high",
status: "overdue",
scheduledTime: "07:00",
estimatedDuration: 90,
assignedTruck: "B-001",
assignedDriver: "Budi Santoso",
driverContact: "081234567890",
estimatedVolume: 280,
actualVolume: 245,
households: 120,
lastCollection: "2025-07-05",
coordinates: { lat: -6.2088, lng: 106.8456 },
urgentIssues: ["Jalan rusak", "Akses terbatas"],
actualStartTime: "07:15",
completedTime: "09:00"
},
{
id: "area-002",
name: "Komplek Permata Indah",
zone: "Zona Selatan",
priority: "medium",
status: "in-progress",
scheduledTime: "08:30",
estimatedDuration: 60,
assignedTruck: "B-003",
assignedDriver: "Sari Dewi",
driverContact: "081234567891",
estimatedVolume: 180,
households: 85,
lastCollection: "2025-07-05",
coordinates: { lat: -6.22, lng: 106.83 },
actualStartTime: "08:45"
},
{
id: "area-003",
name: "Jl. Sudirman Raya",
zone: "Zona Tengah",
priority: "high",
status: "overdue",
scheduledTime: "09:00",
estimatedDuration: 120,
assignedTruck: "B-004",
assignedDriver: "Dedi Kurniawan",
driverContact: "081234567892",
estimatedVolume: 350,
households: 200,
lastCollection: "2025-07-04",
coordinates: { lat: -6.215, lng: 106.84 },
urgentIssues: ["Volume sangat tinggi", "Kemacetan akses"],
notes: "Perlu 2 trip untuk mengangkut semua"
},
{
id: "area-004",
name: "Perumahan Indah Permai",
zone: "Zona Timur",
priority: "medium",
status: "completed",
scheduledTime: "10:00",
estimatedDuration: 75,
assignedTruck: "B-002",
assignedDriver: "Andi Wijaya",
driverContact: "081234567893",
estimatedVolume: 200,
actualVolume: 195,
households: 95,
lastCollection: "2025-07-05",
coordinates: { lat: -6.195, lng: 106.86 },
actualStartTime: "10:15",
completedTime: "11:30"
},
{
id: "area-005",
name: "Pasar Tradisional Sentral",
zone: "Zona Tengah",
priority: "high",
status: "in-progress",
scheduledTime: "11:30",
estimatedDuration: 45,
assignedTruck: "B-005",
assignedDriver: "Rini Astuti",
driverContact: "081234567894",
estimatedVolume: 420,
households: 50,
lastCollection: "2025-07-05",
coordinates: { lat: -6.21, lng: 106.835 },
actualStartTime: "11:45",
notes: "Sampah organik tinggi dari pasar"
},
{
id: "area-006",
name: "Cluster Villa Harmoni",
zone: "Zona Barat",
priority: "low",
status: "pending",
scheduledTime: "13:00",
estimatedDuration: 60,
assignedTruck: "B-006",
assignedDriver: "Toni Setiawan",
driverContact: "081234567895",
estimatedVolume: 150,
households: 75,
lastCollection: "2025-07-05",
coordinates: { lat: -6.205, lng: 106.82 }
}
],
trucks: [
{
id: "B-001",
driver: "Budi Santoso",
contact: "081234567890",
status: "active",
currentLocation: "Kelurahan Merdeka",
currentCapacity: 85,
assignedAreas: ["area-001"],
lastUpdate: "10 menit yang lalu"
},
{
id: "B-002",
driver: "Andi Wijaya",
contact: "081234567893",
status: "available",
currentLocation: "Pool Kendaraan",
currentCapacity: 0,
assignedAreas: ["area-004"],
lastUpdate: "5 menit yang lalu"
},
{
id: "B-003",
driver: "Sari Dewi",
contact: "081234567891",
status: "active",
currentLocation: "Komplek Permata",
currentCapacity: 60,
assignedAreas: ["area-002"],
lastUpdate: "2 menit yang lalu"
},
{
id: "B-004",
driver: "Dedi Kurniawan",
contact: "081234567892",
status: "active",
currentLocation: "Dalam perjalanan",
currentCapacity: 0,
assignedAreas: ["area-003"],
lastUpdate: "15 menit yang lalu"
},
{
id: "B-005",
driver: "Rini Astuti",
contact: "081234567894",
status: "active",
currentLocation: "Pasar Sentral",
currentCapacity: 40,
assignedAreas: ["area-005"],
lastUpdate: "1 menit yang lalu"
},
{
id: "B-006",
driver: "Toni Setiawan",
contact: "081234567895",
status: "available",
currentLocation: "Pool Kendaraan",
currentCapacity: 0,
assignedAreas: ["area-006"],
lastUpdate: "30 menit yang lalu"
}
],
emergencyContacts: {
supervisor: "081234560001",
dispatcher: "081234560002",
maintenance: "081234560003"
}
};
return json(collectionData);
};
export default function PengumpulanHarian() {
const data = useLoaderData<CollectionData>();
const [selectedArea, setSelectedArea] = useState<CollectionArea | null>(null);
const [viewMode, setViewMode] = useState<"list" | "map">("list");
const [filterStatus, setFilterStatus] = useState<string>("all");
const [filterPriority, setFilterPriority] = useState<string>("all");
// Filter areas
const filteredAreas = data.areas.filter((area) => {
const statusMatch = filterStatus === "all" || area.status === filterStatus;
const priorityMatch =
filterPriority === "all" || area.priority === filterPriority;
return statusMatch && priorityMatch;
});
// Calculate progress percentage
const progressPercentage = Math.round(
(data.stats.completedAreas / data.stats.totalAreas) * 100
);
const volumePercentage = Math.round(
(data.stats.collectedVolume / data.stats.totalTargetVolume) * 100
);
const getStatusBadge = (status: string) => {
switch (status) {
case "completed":
return (
<Badge variant="default" className="bg-green-100 text-green-800">
Selesai
</Badge>
);
case "in-progress":
return (
<Badge variant="default" className="bg-blue-100 text-blue-800">
Berlangsung
</Badge>
);
case "pending":
return <Badge variant="secondary">Menunggu</Badge>;
case "overdue":
return <Badge variant="destructive">Terlambat</Badge>;
case "cancelled":
return (
<Badge variant="outline" className="text-red-600">
Dibatalkan
</Badge>
);
default:
return <Badge variant="outline">{status}</Badge>;
}
};
const getPriorityBadge = (priority: string) => {
switch (priority) {
case "high":
return <Badge variant="destructive">Tinggi</Badge>;
case "medium":
return (
<Badge variant="default" className="bg-yellow-100 text-yellow-800">
Sedang
</Badge>
);
case "low":
return <Badge variant="secondary">Rendah</Badge>;
default:
return <Badge variant="outline">{priority}</Badge>;
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "completed":
return <CheckCircle className="h-4 w-4 text-green-600" />;
case "in-progress":
return <RefreshCw className="h-4 w-4 text-blue-600 animate-spin" />;
case "pending":
return <Clock className="h-4 w-4 text-gray-600" />;
case "overdue":
return <AlertTriangle className="h-4 w-4 text-red-600" />;
case "cancelled":
return <XCircle className="h-4 w-4 text-red-600" />;
default:
return <Package className="h-4 w-4 text-gray-600" />;
}
};
const getTruckStatusBadge = (status: string) => {
switch (status) {
case "active":
return (
<Badge variant="default" className="bg-green-100 text-green-800">
Aktif
</Badge>
);
case "available":
return <Badge variant="secondary">Tersedia</Badge>;
case "maintenance":
return (
<Badge variant="default" className="bg-yellow-100 text-yellow-800">
Maintenance
</Badge>
);
case "break":
return <Badge variant="outline">Istirahat</Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
};
return (
<div className="flex-1 space-y-4 p-4 pt-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight flex items-center gap-2">
Pengumpulan Harian
<Badge variant="destructive" className="animate-pulse">
<Zap className="h-3 w-3 mr-1" />
URGENT
</Badge>
</h2>
<p className="text-muted-foreground">
Monitoring dan koordinasi pengumpulan sampah -{" "}
{new Date(data.date).toLocaleDateString("id-ID", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric"
})}
</p>
</div>
<div className="flex items-center space-x-2">
<Button variant="outline" size="sm">
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm">
<AlertTriangle className="mr-2 h-4 w-4" />
Emergency
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Emergency Response</AlertDialogTitle>
<AlertDialogDescription>
Hubungi kontak darurat untuk situasi mendesak
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 border rounded">
<div>
<p className="font-medium">Supervisor</p>
<p className="text-sm text-gray-500">
{data.emergencyContacts.supervisor}
</p>
</div>
<Button size="sm">
<Phone className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center justify-between p-3 border rounded">
<div>
<p className="font-medium">Dispatcher</p>
<p className="text-sm text-gray-500">
{data.emergencyContacts.dispatcher}
</p>
</div>
<Button size="sm">
<Phone className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center justify-between p-3 border rounded">
<div>
<p className="font-medium">Maintenance</p>
<p className="text-sm text-gray-500">
{data.emergencyContacts.maintenance}
</p>
</div>
<Button size="sm">
<Phone className="h-4 w-4" />
</Button>
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Tutup</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button>
<Plus className="mr-2 h-4 w-4" />
Tambah Area
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Progress Harian
</CardTitle>
<Target className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{data.stats.completedAreas}/{data.stats.totalAreas}
</div>
<Progress value={progressPercentage} className="mt-2" />
<p className="text-xs text-muted-foreground mt-2">
{progressPercentage}% area selesai Est. selesai{" "}
{data.stats.estimatedCompletion}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Volume Terkumpul
</CardTitle>
<Package className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{data.stats.collectedVolume.toLocaleString()} kg
</div>
<Progress value={volumePercentage} className="mt-2" />
<p className="text-xs text-muted-foreground mt-2">
{volumePercentage}% dari target (
{data.stats.totalTargetVolume.toLocaleString()} kg)
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Status Truk</CardTitle>
<Truck className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{data.stats.activeTrucks}/
{data.stats.activeTrucks + data.stats.availableTrucks}
</div>
<div className="flex items-center space-x-2 mt-2">
<div className="flex items-center space-x-1">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-xs">{data.stats.activeTrucks} Aktif</span>
</div>
<div className="flex items-center space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full"></div>
<span className="text-xs">
{data.stats.availableTrucks} Tersedia
</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Area Terlambat
</CardTitle>
<AlertTriangle className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
{data.stats.overdueAreas}
</div>
<div className="text-xs text-red-600 mt-2 font-medium">
Perlu penanganan segera
</div>
<div className="text-xs text-muted-foreground">
{data.stats.inProgressAreas} area sedang dikerjakan
</div>
</CardContent>
</Card>
</div>
{/* Filters and View Toggle */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Label htmlFor="status-filter">Status:</Label>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua</SelectItem>
<SelectItem value="pending">Menunggu</SelectItem>
<SelectItem value="in-progress">Berlangsung</SelectItem>
<SelectItem value="completed">Selesai</SelectItem>
<SelectItem value="overdue">Terlambat</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Label htmlFor="priority-filter">Prioritas:</Label>
<Select value={filterPriority} onValueChange={setFilterPriority}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua</SelectItem>
<SelectItem value="high">Tinggi</SelectItem>
<SelectItem value="medium">Sedang</SelectItem>
<SelectItem value="low">Rendah</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center space-x-2">
<Button
variant={viewMode === "list" ? "default" : "outline"}
size="sm"
onClick={() => setViewMode("list")}
>
List
</Button>
<Button
variant={viewMode === "map" ? "default" : "outline"}
size="sm"
onClick={() => setViewMode("map")}
>
Map
</Button>
</div>
</div>
{/* Main Content */}
<Tabs defaultValue="areas" className="space-y-4">
<TabsList>
<TabsTrigger value="areas">Area Pengumpulan</TabsTrigger>
<TabsTrigger value="trucks">Status Truk</TabsTrigger>
<TabsTrigger value="timeline">Timeline Hari Ini</TabsTrigger>
</TabsList>
<TabsContent value="areas" className="space-y-4">
<div className="grid gap-4">
{filteredAreas.map((area) => (
<Card
key={area.id}
className={`${
area.status === "overdue" ? "border-red-200 bg-red-50" : ""
}`}
>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
{getStatusIcon(area.status)}
<div>
<CardTitle className="text-lg">{area.name}</CardTitle>
<CardDescription className="flex items-center space-x-2">
<span>{area.zone}</span>
<span></span>
<span>{area.households} rumah</span>
<span></span>
<span>Truk {area.assignedTruck}</span>
</CardDescription>
</div>
</div>
<div className="flex items-center space-x-2">
{getPriorityBadge(area.priority)}
{getStatusBadge(area.status)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<div className="text-sm font-medium mb-2">
Jadwal & Progress
</div>
<div className="space-y-1 text-sm">
<div className="flex items-center space-x-2">
<Clock className="h-3 w-3" />
<span>Target: {area.scheduledTime}</span>
</div>
{area.actualStartTime && (
<div className="flex items-center space-x-2">
<Timer className="h-3 w-3" />
<span>Mulai: {area.actualStartTime}</span>
</div>
)}
{area.completedTime && (
<div className="flex items-center space-x-2">
<CheckCircle className="h-3 w-3 text-green-600" />
<span>Selesai: {area.completedTime}</span>
</div>
)}
<div className="flex items-center space-x-2">
<Package className="h-3 w-3" />
<span>
{area.actualVolume
? `${area.actualVolume} kg`
: `Est. ${area.estimatedVolume} kg`}
</span>
</div>
</div>
</div>
<div>
<div className="text-sm font-medium mb-2">
Driver & Kontak
</div>
<div className="space-y-1 text-sm">
<div className="flex items-center space-x-2">
<Users className="h-3 w-3" />
<span>{area.assignedDriver}</span>
</div>
<div className="flex items-center space-x-2">
<Phone className="h-3 w-3" />
<span>{area.driverContact}</span>
</div>
<div className="flex items-center space-x-2">
<Truck className="h-3 w-3" />
<span>Truk {area.assignedTruck}</span>
</div>
</div>
</div>
<div>
<div className="text-sm font-medium mb-2">Actions</div>
<div className="flex items-center space-x-2">
<Button size="sm" variant="outline">
<Phone className="h-3 w-3 mr-1" />
Call
</Button>
<Button size="sm" variant="outline">
<MessageSquare className="h-3 w-3 mr-1" />
Chat
</Button>
<Button size="sm" variant="outline">
<Navigation className="h-3 w-3 mr-1" />
Track
</Button>
<Dialog>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Edit className="h-3 w-3 mr-1" />
Edit
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
Update Status - {area.name}
</DialogTitle>
<DialogDescription>
Update status pengumpulan dan informasi terkait
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="status">Status</Label>
<Select defaultValue={area.status}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pending">
Menunggu
</SelectItem>
<SelectItem value="in-progress">
Berlangsung
</SelectItem>
<SelectItem value="completed">
Selesai
</SelectItem>
<SelectItem value="overdue">
Terlambat
</SelectItem>
<SelectItem value="cancelled">
Dibatalkan
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="volume">
Volume Aktual (kg)
</Label>
<Input
id="volume"
type="number"
defaultValue={
area.actualVolume || area.estimatedVolume
}
/>
</div>
<div>
<Label htmlFor="notes">Catatan</Label>
<Input
id="notes"
defaultValue={area.notes || ""}
placeholder="Tambahkan catatan..."
/>
</div>
<Button className="w-full">Update Status</Button>
</div>
</DialogContent>
</Dialog>
</div>
</div>
</div>
{area.urgentIssues && area.urgentIssues.length > 0 && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded">
<div className="flex items-center space-x-2 mb-2">
<AlertTriangle className="h-4 w-4 text-red-600" />
<span className="text-sm font-medium text-red-800">
Isu Mendesak:
</span>
</div>
<ul className="text-sm text-red-700 space-y-1">
{area.urgentIssues.map((issue, index) => (
<li key={index}> {issue}</li>
))}
</ul>
</div>
)}
{area.notes && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded">
<div className="text-sm text-blue-800">
<strong>Catatan:</strong> {area.notes}
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="trucks" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
{data.trucks.map((truck) => (
<Card key={truck.id}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Truck className="h-6 w-6" />
<div>
<CardTitle>Truk {truck.id}</CardTitle>
<CardDescription>{truck.driver}</CardDescription>
</div>
</div>
{getTruckStatusBadge(truck.status)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm">Kapasitas:</span>
<span className="text-sm font-medium">
{truck.currentCapacity}%
</span>
</div>
<Progress value={truck.currentCapacity} />
<div className="flex items-center space-x-2">
<MapPin className="h-4 w-4 text-gray-400" />
<span className="text-sm">{truck.currentLocation}</span>
</div>
<div className="flex items-center space-x-2">
<Clock className="h-4 w-4 text-gray-400" />
<span className="text-sm">
Update: {truck.lastUpdate}
</span>
</div>
<div className="flex items-center space-x-2 pt-2">
<Button size="sm" variant="outline">
<Phone className="h-3 w-3 mr-1" />
Call
</Button>
<Button size="sm" variant="outline">
<Navigation className="h-3 w-3 mr-1" />
Track
</Button>
<Button size="sm" variant="outline">
<MessageSquare className="h-3 w-3 mr-1" />
Chat
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="timeline" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Timeline Pengumpulan Hari Ini</CardTitle>
<CardDescription>
Jadwal dan progress pengumpulan sampah realtime
</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[600px]">
<div className="space-y-4">
{data.areas
.sort((a, b) =>
a.scheduledTime.localeCompare(b.scheduledTime)
)
.map((area, index) => (
<div
key={area.id}
className="flex items-start space-x-4 pb-4 border-b last:border-b-0"
>
<div className="flex-shrink-0 mt-1">
{getStatusIcon(area.status)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">{area.name}</h4>
<div className="flex items-center space-x-2">
{getPriorityBadge(area.priority)}
{getStatusBadge(area.status)}
</div>
</div>
<div className="mt-1 text-sm text-gray-500">
{area.scheduledTime} {area.assignedDriver} Truk{" "}
{area.assignedTruck}
</div>
{area.actualStartTime && (
<div className="mt-1 text-xs text-blue-600">
Dimulai: {area.actualStartTime}
</div>
)}
{area.completedTime && (
<div className="mt-1 text-xs text-green-600">
Selesai: {area.completedTime} Volume:{" "}
{area.actualVolume} kg
</div>
)}
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

25
app/routes/pengelola.tsx Normal file
View File

@ -0,0 +1,25 @@
import { json } from "@remix-run/node";
import { Outlet, useLoaderData } from "@remix-run/react";
import { PengelolaLayoutWrapper } from "~/components/layoutpengelola/layout-wrapper";
export const loader = async () => {
// Data untuk layout bisa diambil di sini
return json({
user: {
name: "Fahmi Kurniawan",
email: "pengelola@example.com",
role: "Pengelola"
}
});
};
export default function PengelolaPanelLayout() {
const { user } = useLoaderData<typeof loader>();
return (
<PengelolaLayoutWrapper>
{/* Outlet akan merender child routes */}
<Outlet />
</PengelolaLayoutWrapper>
);
}

View File

@ -0,0 +1,6 @@
import { redirect } from "@remix-run/node";
export const loader = async () => {
return redirect("/sys-rijig-adminpanel/dashboard");
};

View File

@ -0,0 +1,352 @@
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import {
BarChart3,
Users,
Recycle,
DollarSign,
TrendingUp,
TrendingDown,
Package,
MapPin,
Clock,
AlertCircle
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
export const loader = async () => {
const dashboardData = {
stats: {
totalUsers: 1234,
totalWaste: 5678,
totalRevenue: 98765,
activeCollectors: 45
},
recentTransactions: [
{
id: 1,
user: "Ahmad Rizki",
type: "Plastik",
amount: 15000,
time: "2 menit lalu"
},
{
id: 2,
user: "Siti Nurhaliza",
type: "Kertas",
amount: 8500,
time: "5 menit lalu"
},
{
id: 3,
user: "Budi Santoso",
type: "Logam",
amount: 25000,
time: "10 menit lalu"
},
{
id: 4,
user: "Diana Putri",
type: "Plastik",
amount: 12000,
time: "15 menit lalu"
}
],
wasteStats: [
{ type: "Plastik", percentage: 45, color: "bg-blue-500" },
{ type: "Kertas", percentage: 30, color: "bg-green-500" },
{ type: "Logam", percentage: 15, color: "bg-yellow-500" },
{ type: "Organik", percentage: 10, color: "bg-red-500" }
],
alerts: [
{
id: 1,
message: "Kapasitas gudang Pengepul A mencapai 85%",
type: "warning",
time: "1 jam lalu"
},
{
id: 2,
message: "Harga plastik naik 15% hari ini",
type: "info",
time: "2 jam lalu"
},
{
id: 3,
message: "5 pengepul baru menunggu verifikasi",
type: "urgent",
time: "3 jam lalu"
}
]
};
return json({ dashboardData });
};
export default function AdminDashboard() {
const { dashboardData } = useLoaderData<typeof loader>();
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Dashboard Overview
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Kelola ekosistem pengelolaan sampah secara terpadu
</p>
</div>
<div className="flex gap-3">
<Button variant="outline" className="gap-2">
<BarChart3 className="h-4 w-4" />
Export Report
</Button>
<Button className="gap-2 bg-green-600 hover:bg-green-700">
<TrendingUp className="h-4 w-4" />
View Analytics
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card className="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 border-blue-200 dark:border-blue-800">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-blue-700 dark:text-blue-300">
Total Pengguna
</CardTitle>
<Users className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-900 dark:text-blue-100">
{dashboardData.stats.totalUsers.toLocaleString()}
</div>
<p className="text-xs text-blue-600 dark:text-blue-400 flex items-center gap-1 mt-1">
<TrendingUp className="h-3 w-3" />
+12% dari bulan lalu
</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 border-green-200 dark:border-green-800">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-green-700 dark:text-green-300">
Total Sampah (kg)
</CardTitle>
<Recycle className="h-5 w-5 text-green-600 dark:text-green-400" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-900 dark:text-green-100">
{dashboardData.stats.totalWaste.toLocaleString()}
</div>
<p className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1 mt-1">
<TrendingUp className="h-3 w-3" />
+8% dari bulan lalu
</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 border-purple-200 dark:border-purple-800">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-purple-700 dark:text-purple-300">
Total Revenue
</CardTitle>
<DollarSign className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-purple-900 dark:text-purple-100">
Rp {dashboardData.stats.totalRevenue.toLocaleString()}
</div>
<p className="text-xs text-purple-600 dark:text-purple-400 flex items-center gap-1 mt-1">
<TrendingUp className="h-3 w-3" />
+23% dari bulan lalu
</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-800/20 border-orange-200 dark:border-orange-800">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-orange-700 dark:text-orange-300">
Pengepul Aktif
</CardTitle>
<MapPin className="h-5 w-5 text-orange-600 dark:text-orange-400" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-orange-900 dark:text-orange-100">
{dashboardData.stats.activeCollectors}
</div>
<p className="text-xs text-orange-600 dark:text-orange-400 flex items-center gap-1 mt-1">
<TrendingDown className="h-3 w-3" />
-2% dari bulan lalu
</p>
</CardContent>
</Card>
</div>
{/* Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Recent Transactions */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5 text-green-600" />
Transaksi Terbaru
</CardTitle>
<CardDescription>
Aktivitas transaksi sampah dalam 24 jam terakhir
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{dashboardData.recentTransactions.map((transaction) => (
<div
key={transaction.id}
className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
<Recycle className="h-5 w-5 text-green-600" />
</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">
{transaction.user}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{transaction.type}
</p>
</div>
</div>
<div className="text-right">
<p className="font-semibold text-green-600">
Rp {transaction.amount.toLocaleString()}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<Clock className="h-3 w-3" />
{transaction.time}
</p>
</div>
</div>
))}
</div>
<Button variant="outline" className="w-full mt-4">
Lihat Semua Transaksi
</Button>
</CardContent>
</Card>
{/* Side Panel */}
<div className="space-y-6">
{/* Waste Statistics */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Distribusi Jenis Sampah</CardTitle>
<CardDescription>Persentase berdasarkan volume</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{dashboardData.wasteStats.map((waste, index) => (
<div key={index} className="space-y-2">
<div className="flex justify-between text-sm">
<span className="font-medium">{waste.type}</span>
<span className="text-gray-500">{waste.percentage}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className={`${waste.color} h-2 rounded-full transition-all duration-300`}
style={{ width: `${waste.percentage}%` }}
></div>
</div>
</div>
))}
</CardContent>
</Card>
{/* Alerts */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-yellow-500" />
Notifikasi Penting
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{dashboardData.alerts.map((alert) => (
<div
key={alert.id}
className="p-3 rounded-lg border-l-4 bg-gray-50 dark:bg-gray-800 border-l-yellow-500"
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm text-gray-700 dark:text-gray-300">
{alert.message}
</p>
<Badge
variant={
alert.type === "urgent" ? "destructive" : "secondary"
}
className="text-xs"
>
{alert.type}
</Badge>
</div>
<p className="text-xs text-gray-500 mt-1 flex items-center gap-1">
<Clock className="h-3 w-3" />
{alert.time}
</p>
</div>
))}
</CardContent>
</Card>
</div>
</div>
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
<CardDescription>Aksi cepat untuk mengelola sistem</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Button
variant="outline"
className="h-auto p-4 flex flex-col items-center gap-2"
>
<Users className="h-6 w-6 text-blue-600" />
<span className="text-sm">Kelola User</span>
</Button>
<Button
variant="outline"
className="h-auto p-4 flex flex-col items-center gap-2"
>
<Recycle className="h-6 w-6 text-green-600" />
<span className="text-sm">Data Sampah</span>
</Button>
<Button
variant="outline"
className="h-auto p-4 flex flex-col items-center gap-2"
>
<BarChart3 className="h-6 w-6 text-purple-600" />
<span className="text-sm">Laporan</span>
</Button>
<Button
variant="outline"
className="h-auto p-4 flex flex-col items-center gap-2"
>
<MapPin className="h-6 w-6 text-orange-600" />
<span className="text-sm">Peta Lokasi</span>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,709 @@
import { ActionFunctionArgs, json, LoaderFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useLoaderData } from "@remix-run/react";
import { useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "~/components/ui/select";
import { Textarea } from "~/components/ui/textarea";
import { Alert, AlertDescription } from "~/components/ui/alert";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from "~/components/ui/dialog";
import {
MapPin,
Plus,
Search,
Filter,
Edit,
Trash2,
Eye,
Layers,
Route,
Target,
Truck,
Recycle,
Building,
Users,
Navigation,
Download,
Info
} from "lucide-react";
import { LeafletMap } from "~/components/map/leaflet-map";
// Mock data untuk lokasi
const mockLocations = [
{
id: "1",
name: "TPS Kelurahan Menteng",
type: "tps",
address: "Jl. Menteng Raya No. 45, Jakarta Pusat",
coordinates: [-6.1944, 106.8229] as [number, number],
status: "active",
capacity: 500,
currentLoad: 320,
lastPickup: "2024-01-15 08:30",
coverage: "Menteng, Gondangdia",
population: 15000,
schedule: "Senin, Rabu, Jumat"
},
{
id: "2",
name: "TPS Kelurahan Cikini",
type: "tps",
address: "Jl. Cikini Raya No. 12, Jakarta Pusat",
coordinates: [-6.1889, 106.8317] as [number, number],
status: "active",
capacity: 300,
currentLoad: 180,
lastPickup: "2024-01-15 09:15",
coverage: "Cikini, Pegangsaan",
population: 12000,
schedule: "Selasa, Kamis, Sabtu"
},
{
id: "3",
name: "Rumah Kompos Kemayoran",
type: "composting",
address: "Jl. Kemayoran No. 88, Jakarta Pusat",
coordinates: [-6.1725, 106.8584] as [number, number],
status: "maintenance",
capacity: 1000,
currentLoad: 450,
lastPickup: "2024-01-14 16:00",
coverage: "Kemayoran, Senen",
population: 25000,
schedule: "Harian"
},
{
id: "4",
name: "Bank Sampah Tanah Abang",
type: "waste_bank",
address: "Jl. Tanah Abang II No. 67, Jakarta Pusat",
coordinates: [-6.1822, 106.8142] as [number, number],
status: "active",
capacity: 200,
currentLoad: 95,
lastPickup: "2024-01-15 14:20",
coverage: "Tanah Abang",
population: 8000,
schedule: "Senin, Kamis"
}
];
export async function loader({ request }: LoaderFunctionArgs) {
// Mock statistics
const stats = {
totalLocations: mockLocations.length,
activeLocations: mockLocations.filter((l) => l.status === "active").length,
totalPopulation: mockLocations.reduce((sum, l) => sum + l.population, 0),
averageLoad: Math.round(
mockLocations.reduce(
(sum, l) => sum + (l.currentLoad / l.capacity) * 100,
0
) / mockLocations.length
),
coverageArea: "45.2 km²"
};
return json({ locations: mockLocations, stats });
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
try {
switch (intent) {
case "add-location":
console.log("Adding new location...");
break;
case "edit-location":
console.log("Editing location...");
break;
case "delete-location":
console.log("Deleting location...");
break;
}
return json({ success: true, message: "Operasi berhasil!" });
} catch (error) {
return json(
{ success: false, message: "Terjadi kesalahan." },
{ status: 400 }
);
}
}
export default function AreaCoverage() {
const { locations, stats } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const [selectedLocation, setSelectedLocation] = useState<
(typeof mockLocations)[0] | null
>(null);
const [filter, setFilter] = useState("all");
const [searchTerm, setSearchTerm] = useState("");
const [showAddDialog, setShowAddDialog] = useState(false);
const filteredLocations = locations.filter((location) => {
const matchesFilter =
filter === "all" ||
location.type === filter ||
location.status === filter;
const matchesSearch =
location.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
location.address.toLowerCase().includes(searchTerm.toLowerCase());
return matchesFilter && matchesSearch;
});
const getTypeIcon = (type: string) => {
switch (type) {
case "tps":
return <Building className="h-4 w-4" />;
case "composting":
return <Recycle className="h-4 w-4" />;
case "waste_bank":
return <Target className="h-4 w-4" />;
default:
return <MapPin className="h-4 w-4" />;
}
};
const getTypeLabel = (type: string) => {
switch (type) {
case "tps":
return "TPS";
case "composting":
return "Kompos";
case "waste_bank":
return "Bank Sampah";
default:
return type;
}
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Area Coverage</h1>
<p className="text-muted-foreground">
Kelola dan pantau area cakupan layanan pengelolaan sampah
</p>
</div>
{actionData && (
<Alert
className={actionData.success ? "border-green-500" : "border-red-500"}
>
<Info className="h-4 w-4" />
<AlertDescription>{actionData.message}</AlertDescription>
</Alert>
)}
{/* Statistics Cards */}
<div className="grid gap-4 md:grid-cols-5">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Lokasi</CardTitle>
<MapPin className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalLocations}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Lokasi Aktif</CardTitle>
<Target className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{stats.activeLocations}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Penduduk
</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.totalPopulation.toLocaleString()}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Rata-rata Load
</CardTitle>
<Truck className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.averageLoad}%</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Area Cakupan</CardTitle>
<Layers className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.coverageArea}</div>
</CardContent>
</Card>
</div>
<Tabs defaultValue="map" className="space-y-4">
<div className="flex items-center justify-between">
<TabsList>
<TabsTrigger value="map" className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
Map View
</TabsTrigger>
<TabsTrigger value="list" className="flex items-center gap-2">
<Filter className="h-4 w-4" />
List View
</TabsTrigger>
</TabsList>
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Tambah Lokasi
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Tambah Lokasi Baru</DialogTitle>
<DialogDescription>
Tambahkan lokasi pengelolaan sampah baru
</DialogDescription>
</DialogHeader>
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="add-location" />
<div className="space-y-2">
<Label htmlFor="name">Nama Lokasi</Label>
<Input
id="name"
name="name"
placeholder="TPS Kelurahan..."
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="type">Tipe Lokasi</Label>
<Select name="type" required>
<SelectTrigger>
<SelectValue placeholder="Pilih tipe lokasi" />
</SelectTrigger>
<SelectContent>
<SelectItem value="tps">
TPS (Tempat Pembuangan Sementara)
</SelectItem>
<SelectItem value="composting">Rumah Kompos</SelectItem>
<SelectItem value="waste_bank">Bank Sampah</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="address">Alamat</Label>
<Textarea
id="address"
name="address"
placeholder="Alamat lengkap..."
required
/>
</div>
<div className="grid gap-4 grid-cols-2">
<div className="space-y-2">
<Label htmlFor="latitude">Latitude</Label>
<Input
id="latitude"
name="latitude"
type="number"
step="any"
placeholder="-6.1944"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="longitude">Longitude</Label>
<Input
id="longitude"
name="longitude"
type="number"
step="any"
placeholder="106.8229"
required
/>
</div>
</div>
<div className="grid gap-4 grid-cols-2">
<div className="space-y-2">
<Label htmlFor="capacity">Kapasitas (kg)</Label>
<Input
id="capacity"
name="capacity"
type="number"
placeholder="500"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="population">Populasi Dilayani</Label>
<Input
id="population"
name="population"
type="number"
placeholder="15000"
required
/>
</div>
</div>
<div className="flex justify-end space-x-2">
<Button
type="button"
variant="outline"
onClick={() => setShowAddDialog(false)}
>
Batal
</Button>
<Button type="submit">Tambah Lokasi</Button>
</div>
</Form>
</DialogContent>
</Dialog>
</div>
<TabsContent value="map" className="space-y-4">
<div className="grid gap-6 lg:grid-cols-3">
{/* Map */}
<div className="lg:col-span-2">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Peta Area Coverage</CardTitle>
<CardDescription>
Sebaran lokasi pengelolaan sampah di area Jakarta Pusat
</CardDescription>
</div>
<div className="flex items-center space-x-2">
<Button variant="outline" size="sm">
<Layers className="h-4 w-4 mr-2" />
Layers
</Button>
<Button variant="outline" size="sm">
<Route className="h-4 w-4 mr-2" />
Routes
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<LeafletMap
locations={filteredLocations}
onLocationSelect={setSelectedLocation}
/>
</CardContent>
</Card>
</div>
{/* Location Details Sidebar */}
<div>
<Card>
<CardHeader>
<CardTitle>Detail Lokasi</CardTitle>
<CardDescription>
{selectedLocation
? "Informasi lokasi terpilih"
: "Pilih lokasi di peta untuk melihat detail"}
</CardDescription>
</CardHeader>
<CardContent>
{selectedLocation ? (
<div className="space-y-4">
<div>
<div className="flex items-center space-x-2 mb-2">
{getTypeIcon(selectedLocation.type)}
<h3 className="font-semibold">
{selectedLocation.name}
</h3>
</div>
<p className="text-sm text-muted-foreground">
{selectedLocation.address}
</p>
</div>
<Separator />
<div className="grid gap-3">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">Status</span>
<Badge
variant={
selectedLocation.status === "active"
? "default"
: "destructive"
}
>
{selectedLocation.status}
</Badge>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">Tipe</span>
<span className="text-sm">
{getTypeLabel(selectedLocation.type)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">Kapasitas</span>
<span className="text-sm">
{selectedLocation.capacity} kg
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Load Saat Ini
</span>
<span className="text-sm">
{Math.round(
(selectedLocation.currentLoad /
selectedLocation.capacity) *
100
)}
%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{
width: `${
(selectedLocation.currentLoad /
selectedLocation.capacity) *
100
}%`
}}
></div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Populasi Dilayani
</span>
<span className="text-sm">
{selectedLocation.population.toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Pickup Terakhir
</span>
<span className="text-sm">
{selectedLocation.lastPickup}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">Jadwal</span>
<span className="text-sm">
{selectedLocation.schedule}
</span>
</div>
</div>
<Separator />
<div className="flex space-x-2">
<Button size="sm" variant="outline" className="flex-1">
<Edit className="h-3 w-3 mr-1" />
Edit
</Button>
<Button size="sm" variant="outline" className="flex-1">
<Navigation className="h-3 w-3 mr-1" />
Navigate
</Button>
</div>
</div>
) : (
<div className="text-center py-8">
<MapPin className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Klik pada marker di peta untuk melihat detail lokasi
</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</TabsContent>
<TabsContent value="list" className="space-y-4">
{/* Filters */}
<Card>
<CardHeader>
<CardTitle>Filter & Search</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4 md:flex-row md:items-center">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cari lokasi..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
</div>
<Select value={filter} onValueChange={setFilter}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Lokasi</SelectItem>
<SelectItem value="tps">TPS</SelectItem>
<SelectItem value="composting">Rumah Kompos</SelectItem>
<SelectItem value="waste_bank">Bank Sampah</SelectItem>
<SelectItem value="active">Status: Aktif</SelectItem>
<SelectItem value="maintenance">
Status: Maintenance
</SelectItem>
</SelectContent>
</Select>
<Button variant="outline">
<Download className="h-4 w-4 mr-2" />
Export
</Button>
</div>
</CardContent>
</Card>
{/* Location Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredLocations.map((location) => (
<Card
key={location.id}
className="hover:shadow-md transition-shadow"
>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{getTypeIcon(location.type)}
<CardTitle className="text-lg">{location.name}</CardTitle>
</div>
<Badge
variant={
location.status === "active" ? "default" : "destructive"
}
>
{location.status}
</Badge>
</div>
<CardDescription className="text-sm">
{location.address}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span>Tipe:</span>
<span>{getTypeLabel(location.type)}</span>
</div>
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span>Load:</span>
<span>
{Math.round(
(location.currentLoad / location.capacity) * 100
)}
%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{
width: `${
(location.currentLoad / location.capacity) * 100
}%`
}}
></div>
</div>
</div>
<div className="flex justify-between text-sm">
<span>Populasi:</span>
<span>{location.population.toLocaleString()}</span>
</div>
<div className="flex justify-between text-sm">
<span>Pickup Terakhir:</span>
<span>{location.lastPickup}</span>
</div>
<div className="flex space-x-2 pt-2">
<Button size="sm" variant="outline" className="flex-1">
<Eye className="h-3 w-3 mr-1" />
Detail
</Button>
<Button size="sm" variant="outline" className="flex-1">
<Edit className="h-3 w-3 mr-1" />
Edit
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,208 @@
import { LoaderFunctionArgs, json } from "@remix-run/node";
import { useLoaderData, Link } from "@remix-run/react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Input } from "~/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "~/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "~/components/ui/dropdown-menu";
import {
Plus,
Search,
MoreHorizontal,
Edit,
Trash2,
Eye,
FileText
} from "lucide-react";
// Mock data - ganti dengan data dari database
const mockArtikel = [
{
id: "1",
title: "Cara Memilah Sampah yang Benar",
category: "Edukasi",
status: "published",
author: "Admin",
createdAt: "2024-01-15",
views: 245
},
{
id: "2",
title: "Manfaat Daur Ulang untuk Lingkungan",
category: "Tips",
status: "draft",
author: "Admin",
createdAt: "2024-01-14",
views: 0
}
];
export async function loader({ request }: LoaderFunctionArgs) {
// Di sini Anda akan fetch data dari database
return json({ artikel: mockArtikel });
}
export default function ArtikelBlogIndex() {
const { artikel } = useLoaderData<typeof loader>();
return (
<div className="space-y-6">
{/* Header Actions */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Input
placeholder="Cari artikel..."
className="w-64"
// icon={<Search className="h-4 w-4" />}
/>
</div>
<Button asChild>
<Link to="create">
<Plus className="h-4 w-4 mr-2" />
Tambah Artikel
</Link>
</Button>
</div>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Artikel</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{artikel.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Published</CardTitle>
<Eye className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{artikel.filter((a) => a.status === "published").length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Draft</CardTitle>
<Edit className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{artikel.filter((a) => a.status === "draft").length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Views</CardTitle>
<Eye className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{artikel.reduce((sum, a) => sum + a.views, 0)}
</div>
</CardContent>
</Card>
</div>
{/* Artikel Table */}
<Card>
<CardHeader>
<CardTitle>Daftar Artikel & Blog</CardTitle>
<CardDescription>
Kelola semua artikel dan blog post Anda
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Judul</TableHead>
<TableHead>Kategori</TableHead>
<TableHead>Status</TableHead>
<TableHead>Author</TableHead>
<TableHead>Tanggal</TableHead>
<TableHead>Views</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{artikel.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.title}</TableCell>
<TableCell>
<Badge variant="secondary">{item.category}</Badge>
</TableCell>
<TableCell>
<Badge
variant={
item.status === "published" ? "default" : "outline"
}
>
{item.status}
</Badge>
</TableCell>
<TableCell>{item.author}</TableCell>
<TableCell>{item.createdAt}</TableCell>
<TableCell>{item.views}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to={`edit/${item.id}`}>
<Edit className="h-4 w-4 mr-2" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Eye className="h-4 w-4 mr-2" />
Preview
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,37 @@
import { Outlet } from "@remix-run/react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
import { FileText, BookOpen } from "lucide-react"
export default function ContentManagementLayout() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Content Management</h1>
<p className="text-muted-foreground">
Kelola artikel, blog, tips, dan panduan untuk aplikasi pengelolaan sampah
</p>
</div>
<Tabs defaultValue="artikel-blog" className="space-y-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="artikel-blog" className="flex items-center gap-2">
<FileText className="h-4 w-4" />
Artikel & Blog
</TabsTrigger>
<TabsTrigger value="tips-panduan" className="flex items-center gap-2">
<BookOpen className="h-4 w-4" />
Tips & Panduan
</TabsTrigger>
</TabsList>
<TabsContent value="artikel-blog" className="space-y-4">
<Outlet />
</TabsContent>
<TabsContent value="tips-panduan" className="space-y-4">
<Outlet />
</TabsContent>
</Tabs>
</div>
)
}

View File

@ -0,0 +1,770 @@
import { ActionFunctionArgs, json, LoaderFunctionArgs } from "@remix-run/node"
import { Form, useActionData, useLoaderData } from "@remix-run/react"
import { useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
import { Button } from "~/components/ui/button"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import { Textarea } from "~/components/ui/textarea"
import { Switch } from "~/components/ui/switch"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"
import { Badge } from "~/components/ui/badge"
import { Separator } from "~/components/ui/separator"
import { Alert, AlertDescription } from "~/components/ui/alert"
import {
Settings,
Users,
Bell,
Shield,
Database,
Mail,
Globe,
Palette,
Save,
Upload,
Download,
RefreshCw,
CheckCircle,
AlertTriangle,
Info
} from "lucide-react"
export async function loader({ request }: LoaderFunctionArgs) {
// Mock data - ganti dengan data dari database
const settings = {
// Pengaturan Umum
appName: "EcoWaste Manager",
appDescription: "Sistem Pengelolaan Sampah Terpadu untuk Lingkungan yang Lebih Bersih",
companyName: "PT. Lingkungan Hijau Indonesia",
companyAddress: "Jl. Sudirman No. 123, Jakarta Pusat 10220",
companyPhone: "+62 21 1234567",
companyEmail: "info@ecowaste.com",
timezone: "Asia/Jakarta",
language: "id",
// Pengaturan Sistem
maintenanceMode: false,
registrationEnabled: true,
maxFileSize: "10",
sessionTimeout: "60",
backupFrequency: "daily",
// Pengaturan Email
smtpHost: "smtp.gmail.com",
smtpPort: "587",
smtpUser: "noreply@ecowaste.com",
smtpPassword: "",
emailFrom: "EcoWaste System <noreply@ecowaste.com>",
// Pengaturan Notifikasi
emailNotifications: true,
pushNotifications: false,
smsNotifications: false,
notifyNewUser: true,
notifyLowStock: true,
notifySystemAlert: true,
// Statistik
totalUsers: 245,
totalWaste: "1,250 kg",
lastBackup: "2 jam yang lalu",
systemUptime: "99.9%"
}
return json({ settings })
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const intent = formData.get("intent")
try {
switch (intent) {
case "general":
// Save general settings
console.log("Saving general settings...")
break
case "system":
// Save system settings
console.log("Saving system settings...")
break
case "email":
// Save email settings
console.log("Saving email settings...")
break
case "notifications":
// Save notification settings
console.log("Saving notification settings...")
break
case "backup":
// Trigger backup
console.log("Creating backup...")
break
case "test-email":
// Test email configuration
console.log("Testing email...")
break
}
return json({ success: true, message: "Pengaturan berhasil disimpan!" })
} catch (error) {
return json({ success: false, message: "Gagal menyimpan pengaturan." }, { status: 400 })
}
}
export default function Pengaturan() {
const { settings } = useLoaderData<typeof loader>()
const actionData = useActionData<typeof action>()
const [activeTab, setActiveTab] = useState("overview")
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Pengaturan</h1>
<p className="text-muted-foreground">
Kelola pengaturan dan konfigurasi sistem pengelolaan sampah
</p>
</div>
{actionData && (
<Alert className={actionData.success ? "border-green-500" : "border-red-500"}>
<Info className="h-4 w-4" />
<AlertDescription>{actionData.message}</AlertDescription>
</Alert>
)}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="overview" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
Overview
</TabsTrigger>
<TabsTrigger value="general" className="flex items-center gap-2">
<Globe className="h-4 w-4" />
Umum
</TabsTrigger>
<TabsTrigger value="system" className="flex items-center gap-2">
<Database className="h-4 w-4" />
Sistem
</TabsTrigger>
<TabsTrigger value="notifications" className="flex items-center gap-2">
<Bell className="h-4 w-4" />
Notifikasi
</TabsTrigger>
<TabsTrigger value="backup" className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Backup
</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Pengguna</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{settings.totalUsers}</div>
<p className="text-xs text-muted-foreground">+5 dari bulan lalu</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Sampah</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{settings.totalWaste}</div>
<p className="text-xs text-muted-foreground">Bulan ini</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">System Uptime</CardTitle>
<CheckCircle className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{settings.systemUptime}</div>
<p className="text-xs text-muted-foreground">30 hari terakhir</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Last Backup</CardTitle>
<Shield className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600"></div>
<p className="text-xs text-muted-foreground">{settings.lastBackup}</p>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Status Sistem</CardTitle>
<CardDescription>Kondisi sistem saat ini</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm">Database</span>
<Badge className="bg-green-100 text-green-800">Online</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Email Service</span>
<Badge className="bg-green-100 text-green-800">Connected</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Storage</span>
<Badge className="bg-yellow-100 text-yellow-800">75% Used</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Cache</span>
<Badge className="bg-green-100 text-green-800">Active</Badge>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Aktivitas Terbaru</CardTitle>
<CardDescription>Log aktivitas sistem</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-sm">
<div className="flex justify-between">
<span>Backup otomatis selesai</span>
<span className="text-muted-foreground">2 jam lalu</span>
</div>
</div>
<div className="text-sm">
<div className="flex justify-between">
<span>User baru terdaftar</span>
<span className="text-muted-foreground">4 jam lalu</span>
</div>
</div>
<div className="text-sm">
<div className="flex justify-between">
<span>Cache dibersihkan</span>
<span className="text-muted-foreground">1 hari lalu</span>
</div>
</div>
<div className="text-sm">
<div className="flex justify-between">
<span>Database dioptimasi</span>
<span className="text-muted-foreground">2 hari lalu</span>
</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
{/* General Settings Tab */}
<TabsContent value="general" className="space-y-4">
<Form method="post">
<input type="hidden" name="intent" value="general" />
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Informasi Aplikasi</CardTitle>
<CardDescription>
Pengaturan dasar aplikasi dan identitas perusahaan
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="appName">Nama Aplikasi</Label>
<Input
id="appName"
name="appName"
defaultValue={settings.appName}
placeholder="Nama aplikasi Anda"
/>
</div>
<div className="space-y-2">
<Label htmlFor="companyName">Nama Perusahaan</Label>
<Input
id="companyName"
name="companyName"
defaultValue={settings.companyName}
placeholder="Nama perusahaan"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="appDescription">Deskripsi Aplikasi</Label>
<Textarea
id="appDescription"
name="appDescription"
defaultValue={settings.appDescription}
placeholder="Deskripsi singkat aplikasi"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="companyAddress">Alamat Perusahaan</Label>
<Textarea
id="companyAddress"
name="companyAddress"
defaultValue={settings.companyAddress}
placeholder="Alamat lengkap perusahaan"
rows={2}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="companyPhone">Telepon</Label>
<Input
id="companyPhone"
name="companyPhone"
defaultValue={settings.companyPhone}
placeholder="+62 21 xxxxxx"
/>
</div>
<div className="space-y-2">
<Label htmlFor="companyEmail">Email</Label>
<Input
id="companyEmail"
name="companyEmail"
type="email"
defaultValue={settings.companyEmail}
placeholder="info@company.com"
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Pengaturan Regional</CardTitle>
<CardDescription>
Zona waktu dan bahasa aplikasi
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="timezone">Zona Waktu</Label>
<Select name="timezone" defaultValue={settings.timezone}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Asia/Jakarta">Asia/Jakarta (WIB)</SelectItem>
<SelectItem value="Asia/Makassar">Asia/Makassar (WITA)</SelectItem>
<SelectItem value="Asia/Jayapura">Asia/Jayapura (WIT)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="language">Bahasa</Label>
<Select name="language" defaultValue={settings.language}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="id">Bahasa Indonesia</SelectItem>
<SelectItem value="en">English</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" className="flex items-center gap-2">
<Save className="h-4 w-4" />
Simpan Pengaturan
</Button>
</div>
</div>
</Form>
</TabsContent>
{/* System Settings Tab */}
<TabsContent value="system" className="space-y-4">
<Form method="post">
<input type="hidden" name="intent" value="system" />
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Pengaturan Sistem</CardTitle>
<CardDescription>
Konfigurasi sistem dan keamanan
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-base">Mode Maintenance</Label>
<div className="text-sm text-muted-foreground">
Nonaktifkan akses pengguna untuk maintenance
</div>
</div>
<Switch
name="maintenanceMode"
defaultChecked={settings.maintenanceMode}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-base">Registrasi Pengguna Baru</Label>
<div className="text-sm text-muted-foreground">
Izinkan pendaftaran pengguna baru
</div>
</div>
<Switch
name="registrationEnabled"
defaultChecked={settings.registrationEnabled}
/>
</div>
<Separator />
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="maxFileSize">Ukuran File Maksimal (MB)</Label>
<Input
id="maxFileSize"
name="maxFileSize"
type="number"
defaultValue={settings.maxFileSize}
min="1"
max="100"
/>
</div>
<div className="space-y-2">
<Label htmlFor="sessionTimeout">Session Timeout (menit)</Label>
<Input
id="sessionTimeout"
name="sessionTimeout"
type="number"
defaultValue={settings.sessionTimeout}
min="15"
max="480"
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Tindakan Sistem</CardTitle>
<CardDescription>
Aksi pemeliharaan dan optimasi sistem
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
<Button variant="outline" type="button">
<RefreshCw className="h-4 w-4 mr-2" />
Clear Cache
</Button>
<Button variant="outline" type="button">
<Database className="h-4 w-4 mr-2" />
Optimize Database
</Button>
<Button variant="outline" type="button">
<Download className="h-4 w-4 mr-2" />
Export Logs
</Button>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" className="flex items-center gap-2">
<Save className="h-4 w-4" />
Simpan Pengaturan
</Button>
</div>
</div>
</Form>
</TabsContent>
{/* Notifications Tab */}
<TabsContent value="notifications" className="space-y-4">
<Form method="post">
<input type="hidden" name="intent" value="notifications" />
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Email Configuration</CardTitle>
<CardDescription>
Pengaturan server email untuk notifikasi
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="smtpHost">SMTP Host</Label>
<Input
id="smtpHost"
name="smtpHost"
defaultValue={settings.smtpHost}
placeholder="smtp.gmail.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="smtpPort">SMTP Port</Label>
<Input
id="smtpPort"
name="smtpPort"
defaultValue={settings.smtpPort}
placeholder="587"
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="smtpUser">SMTP Username</Label>
<Input
id="smtpUser"
name="smtpUser"
defaultValue={settings.smtpUser}
placeholder="username@gmail.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="smtpPassword">SMTP Password</Label>
<Input
id="smtpPassword"
name="smtpPassword"
type="password"
defaultValue={settings.smtpPassword}
placeholder="••••••••"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="emailFrom">Email Pengirim</Label>
<Input
id="emailFrom"
name="emailFrom"
defaultValue={settings.emailFrom}
placeholder="EcoWaste System <noreply@ecowaste.com>"
/>
</div>
<Button
type="button"
variant="outline"
onClick={() => {
// Test email function
const form = new FormData()
form.append("intent", "test-email")
fetch("", { method: "POST", body: form })
}}
>
<Mail className="h-4 w-4 mr-2" />
Test Email
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Pengaturan Notifikasi</CardTitle>
<CardDescription>
Atur jenis notifikasi yang akan dikirim
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-base">Email Notifications</Label>
<div className="text-sm text-muted-foreground">
Kirim notifikasi melalui email
</div>
</div>
<Switch
name="emailNotifications"
defaultChecked={settings.emailNotifications}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-base">Notifikasi Pengguna Baru</Label>
<div className="text-sm text-muted-foreground">
Notifikasi saat ada pengguna baru mendaftar
</div>
</div>
<Switch
name="notifyNewUser"
defaultChecked={settings.notifyNewUser}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-base">Alert Sistem</Label>
<div className="text-sm text-muted-foreground">
Notifikasi untuk masalah sistem dan error
</div>
</div>
<Switch
name="notifySystemAlert"
defaultChecked={settings.notifySystemAlert}
/>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" className="flex items-center gap-2">
<Save className="h-4 w-4" />
Simpan Pengaturan
</Button>
</div>
</div>
</Form>
</TabsContent>
{/* Backup Tab */}
<TabsContent value="backup" className="space-y-4">
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Backup & Restore</CardTitle>
<CardDescription>
Kelola backup data dan pemulihan sistem
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-3">
<div className="text-center p-4 border rounded-lg">
<Database className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<div className="text-sm font-medium">Database</div>
<div className="text-xs text-muted-foreground">245 MB</div>
</div>
<div className="text-center p-4 border rounded-lg">
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<div className="text-sm font-medium">Files</div>
<div className="text-xs text-muted-foreground">1.2 GB</div>
</div>
<div className="text-center p-4 border rounded-lg">
<Settings className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<div className="text-sm font-medium">Settings</div>
<div className="text-xs text-muted-foreground">2 KB</div>
</div>
</div>
<Separator />
<div className="space-y-4">
<h4 className="text-sm font-medium">Backup Otomatis</h4>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="backupFrequency">Frekuensi Backup</Label>
<Select name="backupFrequency" defaultValue={settings.backupFrequency}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">Harian</SelectItem>
<SelectItem value="weekly">Mingguan</SelectItem>
<SelectItem value="monthly">Bulanan</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<Separator />
<div className="flex flex-wrap gap-3">
<Form method="post" style={{ display: 'inline' }}>
<input type="hidden" name="intent" value="backup" />
<Button type="submit" className="flex items-center gap-2">
<Database className="h-4 w-4" />
Backup Sekarang
</Button>
</Form>
<Button variant="outline">
<Download className="h-4 w-4 mr-2" />
Download Backup
</Button>
<Button variant="outline">
<Upload className="h-4 w-4 mr-2" />
Restore Backup
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Riwayat Backup</CardTitle>
<CardDescription>
Daftar backup yang tersedia
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[
{ date: "2024-01-15 14:30", size: "1.4 GB", status: "success" },
{ date: "2024-01-14 14:30", size: "1.3 GB", status: "success" },
{ date: "2024-01-13 14:30", size: "1.3 GB", status: "success" },
{ date: "2024-01-12 14:30", size: "1.2 GB", status: "failed" },
].map((backup, index) => (
<div key={index} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center space-x-3">
<div className={`h-2 w-2 rounded-full ${backup.status === 'success' ? 'bg-green-500' : 'bg-red-500'}`} />
<div>
<div className="text-sm font-medium">{backup.date}</div>
<div className="text-xs text-muted-foreground">{backup.size}</div>
</div>
</div>
<div className="flex space-x-2">
<Button size="sm" variant="outline">
<Download className="h-3 w-3" />
</Button>
<Button size="sm" variant="outline">
<RefreshCw className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</div>
)
}

View File

@ -0,0 +1,158 @@
import { LoaderFunctionArgs, json } from "@remix-run/node";
import { useLoaderData, Link } from "@remix-run/react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { BookOpen, Plus, Users, ThumbsUp } from "lucide-react";
// Mock data untuk tips & panduan
const mockTips = [
{
id: "1",
title: "Panduan Lengkap Kompos Rumahan",
category: "Panduan",
difficulty: "Pemula",
likes: 89,
saves: 45,
createdAt: "2024-01-15"
},
{
id: "2",
title: "5 Tips Mengurangi Sampah Plastik",
category: "Tips",
difficulty: "Mudah",
likes: 156,
saves: 78,
createdAt: "2024-01-14"
}
];
export async function loader({ request }: LoaderFunctionArgs) {
return json({ tips: mockTips });
}
export default function TipsPanduanIndex() {
const { tips } = useLoaderData<typeof loader>();
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Tips & Panduan</h2>
<p className="text-muted-foreground">
Kelola tips dan panduan untuk edukasi pengelolaan sampah
</p>
</div>
<Button asChild>
<Link to="create">
<Plus className="h-4 w-4 mr-2" />
Tambah Tips
</Link>
</Button>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Tips</CardTitle>
<BookOpen className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{tips.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Likes</CardTitle>
<ThumbsUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{tips.reduce((sum, t) => sum + t.likes, 0)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Saves</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{tips.reduce((sum, t) => sum + t.saves, 0)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Avg. Engagement
</CardTitle>
<BookOpen className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{Math.round(
tips.reduce((sum, t) => sum + t.likes + t.saves, 0) /
tips.length
)}
</div>
</CardContent>
</Card>
</div>
{/* Tips Grid */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{tips.map((tip) => (
<Card key={tip.id} className="hover:shadow-md transition-shadow">
<CardHeader>
<div className="flex items-center justify-between">
<Badge variant="secondary">{tip.category}</Badge>
<Badge
variant={tip.difficulty === "Mudah" ? "default" : "outline"}
>
{tip.difficulty}
</Badge>
</div>
<CardTitle className="text-lg">{tip.title}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{tip.createdAt}</span>
<div className="flex items-center space-x-4">
<span className="flex items-center">
<ThumbsUp className="h-3 w-3 mr-1" />
{tip.likes}
</span>
<span className="flex items-center">
<BookOpen className="h-3 w-3 mr-1" />
{tip.saves}
</span>
</div>
</div>
<div className="mt-4 flex space-x-2">
<Button size="sm" variant="outline" asChild>
<Link to={`edit/${tip.id}`}>Edit</Link>
</Button>
<Button size="sm" variant="outline">
Preview
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,212 @@
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { Users, UserCheck, UserX, MoreHorizontal, Eye, Edit, Trash2 } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
export const loader = async () => {
const userData = {
summary: {
totalUsers: 1234,
activeUsers: 1100,
pendingVerification: 15,
collectors: 45
},
users: [
{
id: 1,
name: "Ahmad Rizki",
email: "ahmad.rizki@example.com",
role: "Masyarakat",
status: "active",
joinDate: "2024-01-15",
totalTransactions: 25,
avatar: ""
},
{
id: 2,
name: "Siti Nurhaliza",
email: "siti.nurhaliza@example.com",
role: "Pengepul",
status: "pending",
joinDate: "2024-06-20",
totalTransactions: 0,
avatar: ""
},
{
id: 3,
name: "Budi Santoso",
email: "budi.santoso@example.com",
role: "Masyarakat",
status: "active",
joinDate: "2024-03-10",
totalTransactions: 42,
avatar: ""
}
]
};
return json({ userData });
};
export default function UserManagement() {
const { userData } = useLoaderData<typeof loader>();
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Manajemen Pengguna
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Kelola semua pengguna platform RIjig
</p>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Pengguna</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{userData.summary.totalUsers.toLocaleString()}</div>
<p className="text-xs text-muted-foreground">terdaftar di platform</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Pengguna Aktif</CardTitle>
<UserCheck className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{userData.summary.activeUsers.toLocaleString()}</div>
<p className="text-xs text-muted-foreground">pengguna terverifikasi</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Menunggu Verifikasi</CardTitle>
<UserX className="h-4 w-4 text-orange-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-orange-600">{userData.summary.pendingVerification}</div>
<p className="text-xs text-muted-foreground">perlu ditinjau</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Pengepul</CardTitle>
<Users className="h-4 w-4 text-blue-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">{userData.summary.collectors}</div>
<p className="text-xs text-muted-foreground">pengepul aktif</p>
</CardContent>
</Card>
</div>
{/* Users Table */}
<Card>
<CardHeader>
<CardTitle>Daftar Pengguna</CardTitle>
<CardDescription>Semua pengguna yang terdaftar di platform</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Pengguna</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Tanggal Bergabung</TableHead>
<TableHead>Total Transaksi</TableHead>
<TableHead className="text-right">Aksi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{userData.users.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback>
{user.name.split(' ').map(n => n[0]).join('')}
</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{user.name}</div>
</div>
</div>
</TableCell>
<TableCell className="text-gray-600">{user.email}</TableCell>
<TableCell>
<Badge variant="outline">{user.role}</Badge>
</TableCell>
<TableCell>
<Badge
variant={user.status === "active" ? "default" : "secondary"}
className={user.status === "active" ? "bg-green-100 text-green-800" : "bg-orange-100 text-orange-800"}
>
{user.status === "active" ? "Aktif" : "Pending"}
</Badge>
</TableCell>
<TableCell>{new Date(user.joinDate).toLocaleDateString('id-ID')}</TableCell>
<TableCell>{user.totalTransactions}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="gap-2">
<Eye className="h-4 w-4" />
Lihat Detail
</DropdownMenuItem>
<DropdownMenuItem className="gap-2">
<Edit className="h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem className="gap-2 text-red-600">
<Trash2 className="h-4 w-4" />
Hapus
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,297 @@
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import {
Recycle,
Plus,
Search,
Filter,
MoreHorizontal,
Edit,
Trash2,
TrendingUp,
TrendingDown
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Badge } from "~/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "~/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "~/components/ui/dropdown-menu";
export const loader = async () => {
const wasteData = {
summary: {
totalTypes: 24,
totalVolume: 5678,
avgPrice: 2500,
trending: "up"
},
wasteTypes: [
{
id: 1,
name: "Plastik PET",
category: "Plastik",
currentPrice: 3000,
priceChange: "+5%",
volume: 1500,
trend: "up",
status: "active"
},
{
id: 2,
name: "Kertas HVS",
category: "Kertas",
currentPrice: 2000,
priceChange: "-2%",
volume: 2100,
trend: "down",
status: "active"
},
{
id: 3,
name: "Aluminium",
category: "Logam",
currentPrice: 8500,
priceChange: "+12%",
volume: 450,
trend: "up",
status: "active"
},
{
id: 4,
name: "Plastik HDPE",
category: "Plastik",
currentPrice: 2800,
priceChange: "+3%",
volume: 900,
trend: "up",
status: "active"
},
{
id: 5,
name: "Kertas Karton",
category: "Kertas",
currentPrice: 1500,
priceChange: "0%",
volume: 1200,
trend: "stable",
status: "active"
}
]
};
return json({ wasteData });
};
export default function WasteManagement() {
const { wasteData } = useLoaderData<typeof loader>();
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Manajemen Data Sampah
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Kelola jenis sampah, harga, dan volume transaksi
</p>
</div>
<Button className="gap-2 bg-green-600 hover:bg-green-700">
<Plus className="h-4 w-4" />
Tambah Jenis Sampah
</Button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Jenis</CardTitle>
<Recycle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{wasteData.summary.totalTypes}
</div>
<p className="text-xs text-muted-foreground">
jenis sampah terdaftar
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Volume Total</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{wasteData.summary.totalVolume.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">kg bulan ini</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">
Rata-rata Harga
</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
Rp {wasteData.summary.avgPrice.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">per kilogram</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Trend Harga</CardTitle>
{wasteData.summary.trending === "up" ? (
<TrendingUp className="h-4 w-4 text-green-600" />
) : (
<TrendingDown className="h-4 w-4 text-red-600" />
)}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">Naik</div>
<p className="text-xs text-muted-foreground">
dibanding bulan lalu
</p>
</CardContent>
</Card>
</div>
{/* Filter and Search */}
<Card>
<CardHeader>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle>Daftar Jenis Sampah</CardTitle>
<CardDescription>
Kelola jenis sampah dan harga terkini
</CardDescription>
</div>
<div className="flex gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
<Input
placeholder="Cari jenis sampah..."
className="pl-10 w-64"
/>
</div>
<Button variant="outline" className="gap-2">
<Filter className="h-4 w-4" />
Filter
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Nama Sampah</TableHead>
<TableHead>Kategori</TableHead>
<TableHead>Harga Saat Ini</TableHead>
<TableHead>Perubahan</TableHead>
<TableHead>Volume (kg)</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aksi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{wasteData.wasteTypes.map((waste) => (
<TableRow key={waste.id}>
<TableCell className="font-medium">{waste.name}</TableCell>
<TableCell>
<Badge variant="outline">{waste.category}</Badge>
</TableCell>
<TableCell className="font-mono">
Rp {waste.currentPrice.toLocaleString()}
</TableCell>
<TableCell>
<div
className={`flex items-center gap-1 ${
waste.trend === "up"
? "text-green-600"
: waste.trend === "down"
? "text-red-600"
: "text-gray-600"
}`}
>
{waste.trend === "up" && (
<TrendingUp className="h-3 w-3" />
)}
{waste.trend === "down" && (
<TrendingDown className="h-3 w-3" />
)}
<span className="text-sm">{waste.priceChange}</span>
</div>
</TableCell>
<TableCell>{waste.volume.toLocaleString()}</TableCell>
<TableCell>
<Badge
variant={
waste.status === "active" ? "default" : "secondary"
}
className={
waste.status === "active"
? "bg-green-100 text-green-800"
: ""
}
>
{waste.status === "active" ? "Aktif" : "Nonaktif"}
</Badge>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="gap-2">
<Edit className="h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem className="gap-2 text-red-600">
<Trash2 className="h-4 w-4" />
Hapus
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,25 @@
import { json } from "@remix-run/node";
import { Outlet, useLoaderData } from "@remix-run/react";
import { AdminLayoutWrapper } from "~/components/layoutadmin/layout-wrapper";
export const loader = async () => {
// Data untuk layout bisa diambil di sini
return json({
user: {
name: "Musharof",
email: "admin@example.com",
role: "Administrator"
}
});
};
export default function AdminPanelLayout() {
const { user } = useLoaderData<typeof loader>();
return (
<AdminLayoutWrapper>
{/* Outlet akan merender child routes */}
<Outlet />
</AdminLayoutWrapper>
);
}

16
app/sessions.server.tsx Normal file
View File

@ -0,0 +1,16 @@
import {createThemeSessionResolver} from 'remix-themes'
import { createCookieSessionStorage } from "@remix-run/node"
const sessionStorage = createCookieSessionStorage({
cookie: {
name: '__remix-themes',
// domain: 'remix.run',
path: '/',
httpOnly: true,
sameSite: 'lax',
secrets: ['s3cr3t'],
// secure: true,
},
})
export const themeSessionResolver = createThemeSessionResolver(sessionStorage)

View File

@ -10,3 +10,70 @@ body {
color-scheme: dark;
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/tailwind.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "~/components",
"utils": "~/lib/utils",
"ui": "~/components/ui",
"lib": "~/lib",
"hooks": "~/hooks"
},
"iconLibrary": "lucide"
}

1510
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,15 +11,40 @@
"typecheck": "tsc"
},
"dependencies": {
"@bprogress/remix": "^1.0.19",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@remix-run/node": "^2.16.8",
"@remix-run/react": "^2.16.8",
"@remix-run/serve": "^2.16.8",
"@tabler/icons-react": "^3.34.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"isbot": "^4.1.0",
"lucide-react": "^0.525.0",
"motion": "^12.23.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-leaflet": "^4.0.0",
"remix-themes": "^1.6.1",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@remix-run/dev": "^2.16.8",
"@types/leaflet": "^1.9.19",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.7.4",

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

View File

@ -1,22 +1,70 @@
import type { Config } from "tailwindcss";
export default {
content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"],
darkMode: ["class"],
content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {
fontFamily: {
sans: [
"Inter",
"ui-sans-serif",
"system-ui",
"sans-serif",
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji",
],
},
},
extend: {
fontFamily: {
sans: [
'Inter',
'ui-sans-serif',
'system-ui',
'sans-serif',
'Apple Color Emoji',
'Segoe UI Emoji',
'Segoe UI Symbol',
'Noto Color Emoji'
]
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
}
}
},
plugins: [],
plugins: [require("tailwindcss-animate")],
} satisfies Config;