initial commit:migrate next js to remix
This commit is contained in:
parent
b0dcb68132
commit
15f418d803
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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='© <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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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,
|
||||
}
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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,
|
||||
}
|
|
@ -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 }
|
|
@ -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,
|
||||
}
|
|
@ -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 }
|
|
@ -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,
|
||||
}
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
52
app/root.tsx
52
app/root.tsx
|
@ -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}
|
||||
<ProgressProvider startOnLoad>
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
<LiveReload />
|
||||
<Outlet />
|
||||
</ProgressProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
|
|
@ -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'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
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
import { createThemeAction } from "remix-themes"
|
||||
import { themeSessionResolver } from "../sessions.server"
|
||||
|
||||
export const action = createThemeAction(themeSessionResolver)
|
|
@ -0,0 +1,6 @@
|
|||
import { redirect } from "@remix-run/node";
|
||||
|
||||
export const loader = async () => {
|
||||
return redirect("/pengelola/dashboard");
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { redirect } from "@remix-run/node";
|
||||
|
||||
export const loader = async () => {
|
||||
return redirect("/sys-rijig-adminpanel/dashboard");
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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)
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
|
@ -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 |
|
@ -1,22 +1,70 @@
|
|||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
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",
|
||||
],
|
||||
'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))'
|
||||
},
|
||||
plugins: [],
|
||||
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: [require("tailwindcss-animate")],
|
||||
} satisfies Config;
|
||||
|
|
Loading…
Reference in New Issue