2081 lines
63 KiB
TypeScript
2081 lines
63 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
BarChart3,
|
||
Bell,
|
||
Box,
|
||
ChevronDown,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
ChevronsUpDown,
|
||
ClipboardList,
|
||
FileText,
|
||
Globe,
|
||
LayoutDashboard,
|
||
LineChart,
|
||
LogOut,
|
||
Menu,
|
||
MessageSquare,
|
||
Minus,
|
||
Package,
|
||
RefreshCw,
|
||
RotateCcw,
|
||
Search,
|
||
Settings,
|
||
ShoppingCart,
|
||
Star,
|
||
Tag,
|
||
TrendingDown,
|
||
TrendingUp,
|
||
Truck,
|
||
User,
|
||
Users,
|
||
} from "lucide-react";
|
||
import * as React from "react";
|
||
import type { TooltipProps } from "recharts";
|
||
import {
|
||
Bar,
|
||
BarChart,
|
||
CartesianGrid,
|
||
Scatter,
|
||
ScatterChart,
|
||
Tooltip,
|
||
XAxis,
|
||
YAxis,
|
||
ZAxis,
|
||
} from "recharts";
|
||
|
||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Card,
|
||
CardDescription,
|
||
CardFooter,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuCheckboxItem,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuLabel,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import { Input } from "@/components/ui/input";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
Sheet,
|
||
SheetContent,
|
||
SheetHeader,
|
||
SheetTitle,
|
||
SheetTrigger,
|
||
} from "@/components/ui/sheet";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
type NavItem = {
|
||
label: string;
|
||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||
href: string;
|
||
isActive?: boolean;
|
||
children?: NavItem[];
|
||
};
|
||
|
||
type NavGroup = {
|
||
title: string;
|
||
items: NavItem[];
|
||
};
|
||
|
||
type UserData = {
|
||
name: string;
|
||
email: string;
|
||
avatar: string;
|
||
};
|
||
|
||
type ShellData = {
|
||
logo: {
|
||
src: string;
|
||
alt: string;
|
||
title: string;
|
||
};
|
||
navGroups: NavGroup[];
|
||
footerGroup: NavGroup;
|
||
user: UserData;
|
||
};
|
||
|
||
type RichMetricStatItem = {
|
||
title: string;
|
||
value: number;
|
||
trendValue: number;
|
||
footerDelta: number;
|
||
footerSubtextCount: number;
|
||
};
|
||
|
||
type DailyMetric = {
|
||
day: string;
|
||
orderCount: number;
|
||
avgOrderValue: number;
|
||
totalRevenue: number;
|
||
};
|
||
|
||
type ChannelRevenue = {
|
||
month: string;
|
||
online: number;
|
||
inStore: number;
|
||
wholesale: number;
|
||
marketplace: number;
|
||
};
|
||
|
||
type ActivityEvent = {
|
||
id: string;
|
||
type: "order" | "refund" | "stock" | "review" | "shipment";
|
||
message: string;
|
||
timestamp: Date;
|
||
};
|
||
|
||
type OrderStatus = "Processing" | "Shipped" | "Delivered" | "Cancelled";
|
||
|
||
type Order = {
|
||
id: string;
|
||
orderNumber: string;
|
||
customer: string;
|
||
total: number;
|
||
status: OrderStatus;
|
||
date: string;
|
||
};
|
||
|
||
const currencyFormatter = new Intl.NumberFormat("fa-IR", {
|
||
style: "currency",
|
||
currency: "IRR",
|
||
});
|
||
|
||
const compactCurrencyFormatter = new Intl.NumberFormat("fa-IR", {
|
||
style: "currency",
|
||
currency: "IRR",
|
||
notation: "compact",
|
||
maximumFractionDigits: 0,
|
||
});
|
||
|
||
const numberFormatter = new Intl.NumberFormat("fa-IR");
|
||
|
||
const oneDecimalFormatter = new Intl.NumberFormat("fa-IR", {
|
||
maximumFractionDigits: 1,
|
||
});
|
||
|
||
const trendPercentFormatter = new Intl.NumberFormat("fa-IR", {
|
||
style: "percent",
|
||
maximumFractionDigits: 1,
|
||
signDisplay: "exceptZero",
|
||
});
|
||
|
||
const shortDateFormatter = new Intl.DateTimeFormat("fa-IR", {
|
||
month: "short",
|
||
day: "numeric",
|
||
});
|
||
|
||
const timeFormatter = new Intl.DateTimeFormat("fa-IR", {
|
||
hour: "numeric",
|
||
minute: "2-digit",
|
||
});
|
||
|
||
const formatTrendValue = (trendValue: number) => {
|
||
if (trendValue === 0) return "۰٪";
|
||
return trendPercentFormatter.format(trendValue / 100);
|
||
};
|
||
|
||
const orderStatusLabels: Record<OrderStatus, string> = {
|
||
Processing: "در حال پردازش",
|
||
Shipped: "ارسالشده",
|
||
Delivered: "تحویلشده",
|
||
Cancelled: "لغوشده",
|
||
};
|
||
|
||
const mixBase = "transparent";
|
||
|
||
const palette = {
|
||
primary: "var(--primary)",
|
||
secondary: {
|
||
light: `color-mix(in oklch, var(--primary) 75%, ${mixBase})`,
|
||
dark: `color-mix(in oklch, var(--primary) 85%, ${mixBase})`,
|
||
},
|
||
tertiary: {
|
||
light: `color-mix(in oklch, var(--primary) 55%, ${mixBase})`,
|
||
dark: `color-mix(in oklch, var(--primary) 65%, ${mixBase})`,
|
||
},
|
||
quaternary: {
|
||
light: `color-mix(in oklch, var(--primary) 40%, ${mixBase})`,
|
||
dark: `color-mix(in oklch, var(--primary) 45%, ${mixBase})`,
|
||
},
|
||
};
|
||
|
||
const shellData: ShellData = {
|
||
logo: {
|
||
src: "/block-media/shadcnblocks/block/logos/NovaSales-logo.svg",
|
||
alt: "فروشگاه آکمه",
|
||
title: "ری یو آی Store",
|
||
},
|
||
navGroups: [
|
||
{
|
||
title: "داشبورد",
|
||
items: [
|
||
{
|
||
label: "نمای کلی",
|
||
icon: LayoutDashboard,
|
||
href: "#",
|
||
isActive: true,
|
||
},
|
||
{ label: "بلادرنگ", icon: RefreshCw, href: "#" },
|
||
{ label: "گزارشها", icon: BarChart3, href: "#" },
|
||
],
|
||
},
|
||
{
|
||
title: "سفارشها",
|
||
items: [
|
||
{ label: "همه سفارشها", icon: ClipboardList, href: "#" },
|
||
{ label: "مرجوعیها", icon: RotateCcw, href: "#" },
|
||
{ label: "ارسال", icon: Truck, href: "#" },
|
||
{ label: "تکمیل", icon: Package, href: "#" },
|
||
],
|
||
},
|
||
{
|
||
title: "محصولات",
|
||
items: [
|
||
{
|
||
label: "کاتالوگ",
|
||
icon: Box,
|
||
href: "#",
|
||
children: [
|
||
{ label: "همه محصولات", icon: FileText, href: "#" },
|
||
{ label: "دستهبندیها", icon: FileText, href: "#" },
|
||
],
|
||
},
|
||
{ label: "موجودی", icon: Package, href: "#" },
|
||
{ label: "قیمتگذاری", icon: Tag, href: "#" },
|
||
],
|
||
},
|
||
{
|
||
title: "مشتریان",
|
||
items: [
|
||
{ label: "همه مشتریان", icon: Users, href: "#" },
|
||
{ label: "بخشها", icon: BarChart3, href: "#" },
|
||
{ label: "پیامها", icon: MessageSquare, href: "#" },
|
||
],
|
||
},
|
||
{
|
||
title: "تحلیل فروش",
|
||
items: [
|
||
{ label: "تحلیل فروش", icon: LineChart, href: "#" },
|
||
{ label: "ترافیک", icon: Globe, href: "#" },
|
||
{ label: "تبدیل", icon: TrendingUp, href: "#" },
|
||
],
|
||
},
|
||
],
|
||
footerGroup: {
|
||
title: "تنظیمات",
|
||
items: [{ label: "تنظیمات", icon: Settings, href: "#" }],
|
||
},
|
||
user: {
|
||
name: "علی رضایی",
|
||
email: "john@acme.store",
|
||
avatar:
|
||
"/block-media/shadcnblocks/block/avatar-4.webp",
|
||
},
|
||
};
|
||
|
||
const richMetricStats: RichMetricStatItem[] = [
|
||
{
|
||
title: "درآمد کل",
|
||
value: 1_284_350,
|
||
trendValue: 8.2,
|
||
footerDelta: 97_420,
|
||
footerSubtextCount: 18_450,
|
||
},
|
||
{
|
||
title: "سفارشهای امروز",
|
||
value: 342,
|
||
trendValue: 12.5,
|
||
footerDelta: 38,
|
||
footerSubtextCount: 342,
|
||
},
|
||
{
|
||
title: "میانگین ارزش سفارش",
|
||
value: 127.45,
|
||
trendValue: -2.3,
|
||
footerDelta: -3.05,
|
||
footerSubtextCount: 9_800,
|
||
},
|
||
{
|
||
title: "نرخ تبدیل",
|
||
value: 3.42,
|
||
trendValue: 0.8,
|
||
footerDelta: 0.03,
|
||
footerSubtextCount: 145_000,
|
||
},
|
||
];
|
||
|
||
const dailyMetrics: DailyMetric[] = [
|
||
{ day: "Jan 1", orderCount: 142, avgOrderValue: 89, totalRevenue: 12638 },
|
||
{ day: "Jan 2", orderCount: 198, avgOrderValue: 112, totalRevenue: 22176 },
|
||
{ day: "Jan 3", orderCount: 167, avgOrderValue: 95, totalRevenue: 15865 },
|
||
{ day: "Jan 4", orderCount: 245, avgOrderValue: 78, totalRevenue: 19110 },
|
||
{ day: "Jan 5", orderCount: 289, avgOrderValue: 134, totalRevenue: 38726 },
|
||
{ day: "Jan 6", orderCount: 112, avgOrderValue: 156, totalRevenue: 17472 },
|
||
{ day: "Jan 7", orderCount: 178, avgOrderValue: 102, totalRevenue: 18156 },
|
||
{ day: "Jan 8", orderCount: 234, avgOrderValue: 88, totalRevenue: 20592 },
|
||
{ day: "Jan 9", orderCount: 156, avgOrderValue: 145, totalRevenue: 22620 },
|
||
{ day: "Jan 10", orderCount: 267, avgOrderValue: 110, totalRevenue: 29370 },
|
||
{ day: "Jan 11", orderCount: 189, avgOrderValue: 167, totalRevenue: 31563 },
|
||
{ day: "Jan 12", orderCount: 145, avgOrderValue: 92, totalRevenue: 13340 },
|
||
{ day: "Jan 13", orderCount: 278, avgOrderValue: 125, totalRevenue: 34750 },
|
||
{ day: "Jan 14", orderCount: 201, avgOrderValue: 138, totalRevenue: 27738 },
|
||
{ day: "Jan 15", orderCount: 134, avgOrderValue: 176, totalRevenue: 23584 },
|
||
{ day: "Jan 16", orderCount: 256, avgOrderValue: 99, totalRevenue: 25344 },
|
||
{ day: "Jan 17", orderCount: 187, avgOrderValue: 142, totalRevenue: 26554 },
|
||
{ day: "Jan 18", orderCount: 223, avgOrderValue: 85, totalRevenue: 18955 },
|
||
{ day: "Jan 19", orderCount: 165, avgOrderValue: 118, totalRevenue: 19470 },
|
||
{ day: "Jan 20", orderCount: 298, avgOrderValue: 105, totalRevenue: 31290 },
|
||
{ day: "Jan 21", orderCount: 176, avgOrderValue: 162, totalRevenue: 28512 },
|
||
{ day: "Jan 22", orderCount: 210, avgOrderValue: 94, totalRevenue: 19740 },
|
||
{ day: "Jan 23", orderCount: 243, avgOrderValue: 128, totalRevenue: 31104 },
|
||
{ day: "Jan 24", orderCount: 159, avgOrderValue: 151, totalRevenue: 24009 },
|
||
{ day: "Jan 25", orderCount: 281, avgOrderValue: 87, totalRevenue: 24447 },
|
||
{ day: "Jan 26", orderCount: 195, avgOrderValue: 143, totalRevenue: 27885 },
|
||
{ day: "Jan 27", orderCount: 128, avgOrderValue: 189, totalRevenue: 24192 },
|
||
{ day: "Jan 28", orderCount: 267, avgOrderValue: 96, totalRevenue: 25632 },
|
||
{ day: "Jan 29", orderCount: 214, avgOrderValue: 131, totalRevenue: 28034 },
|
||
{ day: "Jan 30", orderCount: 183, avgOrderValue: 117, totalRevenue: 21411 },
|
||
];
|
||
|
||
const dayLabelFormatter = new Intl.DateTimeFormat("fa-IR", {
|
||
month: "short",
|
||
day: "numeric",
|
||
});
|
||
dailyMetrics.forEach((entry, index) => {
|
||
entry.day = dayLabelFormatter.format(new Date(2025, 0, index + 1));
|
||
});
|
||
|
||
const monthFormatter = new Intl.DateTimeFormat("fa-IR", { month: "short" });
|
||
const monthLabel = (monthIndex: number) =>
|
||
monthFormatter.format(new Date(2025, monthIndex, 1));
|
||
|
||
const channelRevenueData: ChannelRevenue[] = [
|
||
{
|
||
month: monthLabel(0),
|
||
online: 42000,
|
||
inStore: 18000,
|
||
wholesale: 12000,
|
||
marketplace: 8000,
|
||
},
|
||
{
|
||
month: monthLabel(1),
|
||
online: 38000,
|
||
inStore: 21000,
|
||
wholesale: 14000,
|
||
marketplace: 9500,
|
||
},
|
||
{
|
||
month: monthLabel(2),
|
||
online: 51000,
|
||
inStore: 19000,
|
||
wholesale: 11000,
|
||
marketplace: 7200,
|
||
},
|
||
{
|
||
month: monthLabel(3),
|
||
online: 45000,
|
||
inStore: 22000,
|
||
wholesale: 15000,
|
||
marketplace: 10800,
|
||
},
|
||
{
|
||
month: monthLabel(4),
|
||
online: 58000,
|
||
inStore: 24000,
|
||
wholesale: 13000,
|
||
marketplace: 11500,
|
||
},
|
||
{
|
||
month: monthLabel(5),
|
||
online: 62000,
|
||
inStore: 20000,
|
||
wholesale: 16000,
|
||
marketplace: 12300,
|
||
},
|
||
];
|
||
|
||
const GAP_OFFSET = 2500;
|
||
|
||
const fullYearData = [
|
||
{
|
||
quarter: "س۱ '۲۱",
|
||
thisYear: 28000,
|
||
lastYear: -18000,
|
||
gapUp: GAP_OFFSET,
|
||
gapDown: -GAP_OFFSET,
|
||
},
|
||
{
|
||
quarter: "س۲ '۲۱",
|
||
thisYear: 35000,
|
||
lastYear: -22000,
|
||
gapUp: GAP_OFFSET,
|
||
gapDown: -GAP_OFFSET,
|
||
},
|
||
{
|
||
quarter: "س۳ '۲۱",
|
||
thisYear: 32000,
|
||
lastYear: -20000,
|
||
gapUp: GAP_OFFSET,
|
||
gapDown: -GAP_OFFSET,
|
||
},
|
||
{
|
||
quarter: "س۱ '۲۲",
|
||
thisYear: 42000,
|
||
lastYear: -25000,
|
||
gapUp: GAP_OFFSET,
|
||
gapDown: -GAP_OFFSET,
|
||
},
|
||
{
|
||
quarter: "س۲ '۲۲",
|
||
thisYear: 48000,
|
||
lastYear: -28000,
|
||
gapUp: GAP_OFFSET,
|
||
gapDown: -GAP_OFFSET,
|
||
},
|
||
{
|
||
quarter: "س۳ '۲۲",
|
||
thisYear: 45000,
|
||
lastYear: -24000,
|
||
gapUp: GAP_OFFSET,
|
||
gapDown: -GAP_OFFSET,
|
||
},
|
||
{
|
||
quarter: "س۱ '۲۳",
|
||
thisYear: 55000,
|
||
lastYear: -30000,
|
||
gapUp: GAP_OFFSET,
|
||
gapDown: -GAP_OFFSET,
|
||
},
|
||
{
|
||
quarter: "س۲ '۲۳",
|
||
thisYear: 62000,
|
||
lastYear: -32000,
|
||
gapUp: GAP_OFFSET,
|
||
gapDown: -GAP_OFFSET,
|
||
},
|
||
{
|
||
quarter: "س۳ '۲۳",
|
||
thisYear: 58000,
|
||
lastYear: -27000,
|
||
gapUp: GAP_OFFSET,
|
||
gapDown: -GAP_OFFSET,
|
||
},
|
||
{
|
||
quarter: "س۱ '۲۴",
|
||
thisYear: 65000,
|
||
lastYear: -35000,
|
||
gapUp: GAP_OFFSET,
|
||
gapDown: -GAP_OFFSET,
|
||
},
|
||
{
|
||
quarter: "س۲ '۲۴",
|
||
thisYear: 70000,
|
||
lastYear: -33000,
|
||
gapUp: GAP_OFFSET,
|
||
gapDown: -GAP_OFFSET,
|
||
},
|
||
{
|
||
quarter: "س۳ '۲۴",
|
||
thisYear: 68000,
|
||
lastYear: -30000,
|
||
gapUp: GAP_OFFSET,
|
||
gapDown: -GAP_OFFSET,
|
||
},
|
||
];
|
||
|
||
const revenueDomainMax =
|
||
Math.ceil(
|
||
Math.max(
|
||
...fullYearData.map((entry) =>
|
||
Math.max(
|
||
Math.abs(entry.thisYear) + GAP_OFFSET,
|
||
Math.abs(entry.lastYear) + GAP_OFFSET,
|
||
),
|
||
),
|
||
) / 5000,
|
||
) * 5000;
|
||
|
||
const activityEvents: ActivityEvent[] = [
|
||
{
|
||
id: "1",
|
||
type: "order",
|
||
message: "سفارش جدید #ORD-4892 توسط مریم J. ثبت شد",
|
||
timestamp: new Date(2025, 0, 31, 14, 32),
|
||
},
|
||
{
|
||
id: "2",
|
||
type: "shipment",
|
||
message: "سفارش #ORD-4888 با FedEx ارسال شد",
|
||
timestamp: new Date(2025, 0, 31, 13, 15),
|
||
},
|
||
{
|
||
id: "3",
|
||
type: "review",
|
||
message: "امتیاز ۵ ستاره برای Wireless Headphones حرفهای",
|
||
timestamp: new Date(2025, 0, 31, 12, 48),
|
||
},
|
||
{
|
||
id: "4",
|
||
type: "refund",
|
||
message: "بازپرداخت سفارش #ORD-4870 انجام شد",
|
||
timestamp: new Date(2025, 0, 31, 11, 22),
|
||
},
|
||
{
|
||
id: "5",
|
||
type: "stock",
|
||
message: "هشدار موجودی کم: USB-C Charger (۱۲ عدد)",
|
||
timestamp: new Date(2025, 0, 31, 10, 55),
|
||
},
|
||
{
|
||
id: "6",
|
||
type: "order",
|
||
message: "سفارش جدید #ORD-4891 توسط Michael C. ثبت شد",
|
||
timestamp: new Date(2025, 0, 31, 10, 10),
|
||
},
|
||
{
|
||
id: "7",
|
||
type: "shipment",
|
||
message: "سفارش #ORD-4885 با موفقیت تحویل شد",
|
||
timestamp: new Date(2025, 0, 31, 9, 45),
|
||
},
|
||
{
|
||
id: "8",
|
||
type: "review",
|
||
message: "امتیاز ۴ ستاره برای Organic Cotton Tee",
|
||
timestamp: new Date(2025, 0, 31, 9, 20),
|
||
},
|
||
{
|
||
id: "9",
|
||
type: "stock",
|
||
message: "هشدار موجودی کم: Yoga Mat Premium (۵ عدد)",
|
||
timestamp: new Date(2025, 0, 31, 8, 30),
|
||
},
|
||
{
|
||
id: "10",
|
||
type: "order",
|
||
message: "سفارش جدید #ORD-4890 توسط Emma W. ثبت شد",
|
||
timestamp: new Date(2025, 0, 31, 8, 5),
|
||
},
|
||
{
|
||
id: "11",
|
||
type: "refund",
|
||
message: "درخواست بازپرداخت برای سفارش #ORD-4862",
|
||
timestamp: new Date(2025, 0, 31, 7, 40),
|
||
},
|
||
{
|
||
id: "12",
|
||
type: "shipment",
|
||
message: "سفارش #ORD-4882 در مسیر تحویل است",
|
||
timestamp: new Date(2025, 0, 31, 7, 15),
|
||
},
|
||
];
|
||
|
||
const activityIcons: Record<
|
||
ActivityEvent["type"],
|
||
React.ComponentType<React.SVGProps<SVGSVGElement>>
|
||
> = {
|
||
order: ShoppingCart,
|
||
refund: RotateCcw,
|
||
stock: Package,
|
||
review: Star,
|
||
shipment: Truck,
|
||
};
|
||
|
||
const activityColors: Record<ActivityEvent["type"], string> = {
|
||
order:
|
||
"text-emerald-600 bg-emerald-50 dark:text-emerald-400 dark:bg-emerald-900/30",
|
||
refund: "text-red-600 bg-red-50 dark:text-red-400 dark:bg-red-900/30",
|
||
stock: "text-amber-600 bg-amber-50 dark:text-amber-400 dark:bg-amber-900/30",
|
||
review: "text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-900/30",
|
||
shipment:
|
||
"text-violet-600 bg-violet-50 dark:text-violet-400 dark:bg-violet-900/30",
|
||
};
|
||
|
||
const orderStatuses: OrderStatus[] = [
|
||
"Processing",
|
||
"Shipped",
|
||
"Delivered",
|
||
"Cancelled",
|
||
];
|
||
|
||
const statusStyles: Record<OrderStatus, string> = {
|
||
Processing:
|
||
"bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-400 dark:ring-amber-400/20",
|
||
Shipped:
|
||
"bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-700/10 dark:bg-blue-900/30 dark:text-blue-400 dark:ring-blue-400/20",
|
||
Delivered:
|
||
"bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-600/20 dark:bg-emerald-900/30 dark:text-emerald-400 dark:ring-emerald-400/20",
|
||
Cancelled:
|
||
"bg-red-50 text-red-700 ring-1 ring-inset ring-red-600/10 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-400/20",
|
||
};
|
||
|
||
const orders: Order[] = [
|
||
{
|
||
id: "1",
|
||
orderNumber: "ORD-2025-001",
|
||
customer: "Sarah Johnson",
|
||
status: "Delivered",
|
||
total: 2499.0,
|
||
date: "2025-01-31",
|
||
},
|
||
{
|
||
id: "2",
|
||
orderNumber: "ORD-2025-002",
|
||
customer: "Michael Chen",
|
||
status: "Shipped",
|
||
total: 1348.0,
|
||
date: "2025-01-30",
|
||
},
|
||
{
|
||
id: "3",
|
||
orderNumber: "ORD-2025-003",
|
||
customer: "Emma Wilson",
|
||
status: "Processing",
|
||
total: 1198.0,
|
||
date: "2025-01-30",
|
||
},
|
||
{
|
||
id: "4",
|
||
orderNumber: "ORD-2025-004",
|
||
customer: "James Rodriguez",
|
||
status: "Delivered",
|
||
total: 799.0,
|
||
date: "2025-01-29",
|
||
},
|
||
{
|
||
id: "5",
|
||
orderNumber: "ORD-2025-005",
|
||
customer: "Lisa Park",
|
||
status: "Cancelled",
|
||
total: 599.0,
|
||
date: "2025-01-29",
|
||
},
|
||
{
|
||
id: "6",
|
||
orderNumber: "ORD-2025-006",
|
||
customer: "David Kim",
|
||
status: "Shipped",
|
||
total: 5498.0,
|
||
date: "2025-01-28",
|
||
},
|
||
{
|
||
id: "7",
|
||
orderNumber: "ORD-2025-007",
|
||
customer: "Anna Martinez",
|
||
status: "Delivered",
|
||
total: 1199.0,
|
||
date: "2025-01-28",
|
||
},
|
||
{
|
||
id: "8",
|
||
orderNumber: "ORD-2025-008",
|
||
customer: "Robert Taylor",
|
||
status: "Processing",
|
||
total: 1128.0,
|
||
date: "2025-01-27",
|
||
},
|
||
{
|
||
id: "9",
|
||
orderNumber: "ORD-2025-009",
|
||
customer: "Jennifer Lee",
|
||
status: "Shipped",
|
||
total: 449.0,
|
||
date: "2025-01-27",
|
||
},
|
||
{
|
||
id: "10",
|
||
orderNumber: "ORD-2025-010",
|
||
customer: "William Brown",
|
||
status: "Delivered",
|
||
total: 2199.0,
|
||
date: "2025-01-26",
|
||
},
|
||
{
|
||
id: "11",
|
||
orderNumber: "ORD-2025-011",
|
||
customer: "Sophia Davis",
|
||
status: "Cancelled",
|
||
total: 349.0,
|
||
date: "2025-01-26",
|
||
},
|
||
{
|
||
id: "12",
|
||
orderNumber: "ORD-2025-012",
|
||
customer: "Daniel Garcia",
|
||
status: "Processing",
|
||
total: 899.0,
|
||
date: "2025-01-25",
|
||
},
|
||
{
|
||
id: "13",
|
||
orderNumber: "ORD-2025-013",
|
||
customer: "Olivia White",
|
||
status: "Delivered",
|
||
total: 3250.0,
|
||
date: "2025-01-25",
|
||
},
|
||
{
|
||
id: "14",
|
||
orderNumber: "ORD-2025-014",
|
||
customer: "Thomas Harris",
|
||
status: "Shipped",
|
||
total: 742.0,
|
||
date: "2025-01-24",
|
||
},
|
||
{
|
||
id: "15",
|
||
orderNumber: "ORD-2025-015",
|
||
customer: "Rachel Clark",
|
||
status: "Processing",
|
||
total: 1875.0,
|
||
date: "2025-01-24",
|
||
},
|
||
{
|
||
id: "16",
|
||
orderNumber: "ORD-2025-016",
|
||
customer: "Kevin Lewis",
|
||
status: "Delivered",
|
||
total: 564.0,
|
||
date: "2025-01-23",
|
||
},
|
||
{
|
||
id: "17",
|
||
orderNumber: "ORD-2025-017",
|
||
customer: "Amanda Robinson",
|
||
status: "Cancelled",
|
||
total: 2100.0,
|
||
date: "2025-01-23",
|
||
},
|
||
{
|
||
id: "18",
|
||
orderNumber: "ORD-2025-018",
|
||
customer: "Chris Walker",
|
||
status: "Shipped",
|
||
total: 1450.0,
|
||
date: "2025-01-22",
|
||
},
|
||
{
|
||
id: "19",
|
||
orderNumber: "ORD-2025-019",
|
||
customer: "Natalie Young",
|
||
status: "Delivered",
|
||
total: 890.0,
|
||
date: "2025-01-22",
|
||
},
|
||
{
|
||
id: "20",
|
||
orderNumber: "ORD-2025-020",
|
||
customer: "Brian Allen",
|
||
status: "Processing",
|
||
total: 3675.0,
|
||
date: "2025-01-21",
|
||
},
|
||
];
|
||
|
||
const PAGE_SIZE_OPTIONS = [10, 25, 50];
|
||
|
||
const scatterChartConfig = {
|
||
scatter: { label: "شاخصهای روزانه", color: palette.primary },
|
||
} satisfies ChartConfig;
|
||
|
||
const channelChartConfig = {
|
||
online: { label: "آنلاین", color: palette.primary },
|
||
inStore: { label: "حضوری", theme: palette.secondary },
|
||
wholesale: { label: "عمدهفروشی", theme: palette.tertiary },
|
||
marketplace: { label: "مارکتپلیس", theme: palette.quaternary },
|
||
} satisfies ChartConfig;
|
||
|
||
const revenueChartConfig = {
|
||
thisYear: { label: "امسال", color: palette.primary },
|
||
lastYear: { label: "سال قبل", theme: palette.secondary },
|
||
} satisfies ChartConfig;
|
||
|
||
const chartHeaderClass =
|
||
"flex h-14 items-center justify-between border-b px-4 sm:px-5";
|
||
const chartIconBtnClass = "size-7 sm:size-8";
|
||
const chartIconClass = "size-4 text-muted-foreground sm:size-[18px]";
|
||
const chartTitleClass = "text-sm font-medium text-pretty sm:text-base";
|
||
const tooltipLabelClass = "text-[10px] text-muted-foreground sm:text-xs";
|
||
const tooltipValueClass = "text-[10px] font-medium text-foreground sm:text-xs";
|
||
|
||
const NavDropdown = ({
|
||
group,
|
||
isActive,
|
||
onSelect,
|
||
}: {
|
||
group: NavGroup;
|
||
isActive: boolean;
|
||
onSelect: () => void;
|
||
}) => {
|
||
return (
|
||
<Button
|
||
variant="ghost"
|
||
className={cn("gap-1", isActive && "bg-muted")}
|
||
onClick={onSelect}
|
||
>
|
||
{group.title}
|
||
</Button>
|
||
);
|
||
};
|
||
|
||
const SubNav = ({
|
||
group,
|
||
activeItem,
|
||
onSelectItem,
|
||
}: {
|
||
group: NavGroup;
|
||
activeItem: NavItem | null;
|
||
onSelectItem: (item: NavItem) => void;
|
||
}) => {
|
||
return (
|
||
<div className="border-b bg-muted/30">
|
||
<div className="mx-auto flex h-10 w-full max-w-7xl items-center gap-1 px-4 lg:px-6">
|
||
{group.items.map((item) => {
|
||
const Icon = item.icon;
|
||
const isActive = activeItem?.label === item.label;
|
||
const hasChildren = item.children && item.children.length > 0;
|
||
|
||
if (hasChildren) {
|
||
return (
|
||
<DropdownMenu key={item.label}>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className={cn(
|
||
"h-7 gap-1.5 text-sm",
|
||
isActive && "bg-accent text-accent-foreground",
|
||
)}
|
||
>
|
||
<Icon className="size-4" aria-hidden="true" />
|
||
{item.label}
|
||
<ChevronDown className="size-3" aria-hidden="true" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="start">
|
||
{item.children!.map((child) => {
|
||
const ChildIcon = child.icon;
|
||
return (
|
||
<DropdownMenuItem key={child.label} asChild>
|
||
<a
|
||
href={child.href}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<ChildIcon className="size-4" aria-hidden="true" />
|
||
{child.label}
|
||
</a>
|
||
</DropdownMenuItem>
|
||
);
|
||
})}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Button
|
||
key={item.label}
|
||
variant="ghost"
|
||
size="sm"
|
||
className={cn(
|
||
"h-7 gap-1.5 text-sm",
|
||
isActive && "bg-accent text-accent-foreground",
|
||
)}
|
||
onClick={() => onSelectItem(item)}
|
||
>
|
||
<Icon className="size-4" aria-hidden="true" />
|
||
{item.label}
|
||
</Button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const MobileNav = ({
|
||
activeGroupIndex,
|
||
onSelectGroup,
|
||
}: {
|
||
activeGroupIndex: number;
|
||
onSelectGroup: (index: number) => void;
|
||
}) => {
|
||
return (
|
||
<Sheet>
|
||
<SheetTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="md:hidden"
|
||
aria-label="باز کردن منوی ناوبری"
|
||
>
|
||
<Menu className="size-5" aria-hidden="true" />
|
||
</Button>
|
||
</SheetTrigger>
|
||
<SheetContent side="right" className="w-72">
|
||
<SheetHeader>
|
||
<SheetTitle className="flex items-center gap-2">
|
||
<img
|
||
src={shellData.logo.src}
|
||
alt={shellData.logo.alt}
|
||
className="size-6"
|
||
/>
|
||
{shellData.logo.title}
|
||
</SheetTitle>
|
||
</SheetHeader>
|
||
<nav className="mt-6 flex flex-col gap-4">
|
||
{shellData.navGroups.map((group, index) => (
|
||
<div key={group.title}>
|
||
<button
|
||
onClick={() => onSelectGroup(index)}
|
||
className={cn(
|
||
"mb-2 text-xs font-medium tracking-wider uppercase",
|
||
index === activeGroupIndex
|
||
? "text-foreground"
|
||
: "text-muted-foreground",
|
||
)}
|
||
>
|
||
{group.title}
|
||
</button>
|
||
{index === activeGroupIndex && (
|
||
<div className="flex flex-col gap-1">
|
||
{group.items.map((item) => {
|
||
const Icon = item.icon;
|
||
return (
|
||
<React.Fragment key={item.label}>
|
||
<a
|
||
href={item.href}
|
||
className={cn(
|
||
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted",
|
||
item.isActive && "bg-muted font-medium",
|
||
)}
|
||
>
|
||
<Icon className="size-4" aria-hidden="true" />
|
||
{item.label}
|
||
</a>
|
||
{item.children?.map((child) => {
|
||
const ChildIcon = child.icon;
|
||
return (
|
||
<a
|
||
key={child.label}
|
||
href={child.href}
|
||
className="flex items-center gap-2 rounded-md py-1.5 pe-2 ps-8 text-sm hover:bg-muted"
|
||
>
|
||
<ChildIcon
|
||
className="size-4"
|
||
aria-hidden="true"
|
||
/>
|
||
{child.label}
|
||
</a>
|
||
);
|
||
})}
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
<div className="mt-auto border-t pt-4">
|
||
{shellData.footerGroup.items.map((item) => {
|
||
const Icon = item.icon;
|
||
return (
|
||
<a
|
||
key={item.label}
|
||
href={item.href}
|
||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-muted"
|
||
>
|
||
<Icon className="size-4" aria-hidden="true" />
|
||
{item.label}
|
||
</a>
|
||
);
|
||
})}
|
||
</div>
|
||
</nav>
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
};
|
||
|
||
const NavUser = ({ user }: { user: UserData }) => {
|
||
return (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="ghost" className="gap-2 px-2">
|
||
<Avatar className="size-8">
|
||
<AvatarImage src={user.avatar} alt={user.name} />
|
||
<AvatarFallback>
|
||
{user.name
|
||
.split(" ")
|
||
.map((n) => n[0])
|
||
.join("")}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<span className="hidden text-sm font-medium md:inline">
|
||
{user.name}
|
||
</span>
|
||
<ChevronsUpDown
|
||
className="hidden size-4 md:block"
|
||
aria-hidden="true"
|
||
/>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-56">
|
||
<DropdownMenuLabel className="font-normal">
|
||
<div className="flex flex-col space-y-1">
|
||
<p className="text-sm font-medium">{user.name}</p>
|
||
<p className="text-xs text-muted-foreground">{user.email}</p>
|
||
</div>
|
||
</DropdownMenuLabel>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem>
|
||
<User className="me-2 size-4" aria-hidden="true" />
|
||
حساب کاربری
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem>
|
||
<LogOut className="me-2 size-4" aria-hidden="true" />
|
||
خروج
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
);
|
||
};
|
||
|
||
const DashboardHeader = () => {
|
||
return (
|
||
<div className="flex flex-col gap-0.5">
|
||
<h1 className="text-lg font-semibold sm:text-xl">صبح بخیر، جیمز.</h1>
|
||
<p className="text-sm text-muted-foreground">
|
||
وضعیت فروشگاه شما در امروز:
|
||
</p>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const StatCards = () => {
|
||
return (
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||
{richMetricStats.map((stat) => {
|
||
const isPositive = stat.trendValue > 0;
|
||
const isNeutral = stat.trendValue === 0;
|
||
|
||
const Icon = isNeutral ? Minus : isPositive ? TrendingUp : TrendingDown;
|
||
|
||
const trendClass = isNeutral
|
||
? "text-foreground bg-muted"
|
||
: isPositive
|
||
? "text-success border-success/20 bg-success/10"
|
||
: "text-destructive border-destructive/20 bg-destructive/10";
|
||
|
||
const formattedTrend = formatTrendValue(stat.trendValue);
|
||
|
||
let formattedValue: string;
|
||
if (stat.title === "نرخ تبدیل") {
|
||
formattedValue = `${oneDecimalFormatter.format(stat.value)}٪`;
|
||
} else if (stat.title === "سفارشهای امروز") {
|
||
formattedValue = numberFormatter.format(stat.value);
|
||
} else {
|
||
formattedValue = currencyFormatter.format(stat.value);
|
||
}
|
||
|
||
let footerLabel: string;
|
||
if (stat.title === "نرخ تبدیل") {
|
||
const deltaPrefix = stat.footerDelta > 0 ? "+" : "";
|
||
footerLabel = `${deltaPrefix}${oneDecimalFormatter.format(stat.footerDelta)}٪ نسبت به ماه قبل`;
|
||
} else if (stat.title === "سفارشهای امروز") {
|
||
const deltaPrefix = stat.footerDelta > 0 ? "+" : "";
|
||
footerLabel = `${deltaPrefix}${numberFormatter.format(stat.footerDelta)} نسبت به دیروز`;
|
||
} else {
|
||
const deltaPrefix =
|
||
stat.footerDelta > 0 ? "+" : stat.footerDelta < 0 ? "-" : "";
|
||
footerLabel = `${deltaPrefix}${currencyFormatter.format(Math.abs(stat.footerDelta))} نسبت به ماه قبل`;
|
||
}
|
||
|
||
const footerSubtext = `بر اساس ${numberFormatter.format(stat.footerSubtextCount)} ${stat.title === "نرخ تبدیل" ? "بازدید" : "سفارش"}`;
|
||
|
||
return (
|
||
<Card key={stat.title} className="@container/card shadow-none">
|
||
<CardHeader className="flex items-start justify-between gap-2">
|
||
<div>
|
||
<CardDescription className="font-medium">
|
||
{stat.title}
|
||
</CardDescription>
|
||
<CardTitle className="text-xl font-bold @[600px]/card:text-xl @[800px]/card:text-2xll">
|
||
{formattedValue}
|
||
</CardTitle>
|
||
</div>
|
||
<span
|
||
className={cn(
|
||
"inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs font-medium",
|
||
trendClass,
|
||
)}
|
||
>
|
||
<Icon className="size-3" aria-hidden="true" />
|
||
{formattedTrend}
|
||
</span>
|
||
</CardHeader>
|
||
|
||
<CardFooter className="flex-col items-start gap-1 text-sm">
|
||
<div className="line-clamp-1 flex items-center gap-1.5 font-medium">
|
||
<span
|
||
className={
|
||
isNeutral
|
||
? ""
|
||
: isPositive
|
||
? "text-success"
|
||
: "text-destructive"
|
||
}
|
||
>
|
||
{footerLabel}
|
||
</span>
|
||
<Icon
|
||
className={cn(
|
||
"size-3.5",
|
||
isNeutral
|
||
? "text-muted-foreground"
|
||
: isPositive
|
||
? "text-success"
|
||
: "text-destructive",
|
||
)}
|
||
aria-hidden="true"
|
||
/>
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">
|
||
{footerSubtext}
|
||
</div>
|
||
</CardFooter>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
function ScatterTooltip({ active, payload }: any) {
|
||
if (!active || !payload?.length) return null;
|
||
const data = payload[0]?.payload as DailyMetric | undefined;
|
||
if (!data) return null;
|
||
|
||
return (
|
||
<div className="rounded-lg border border-border bg-popover px-3 py-2 shadow-lg">
|
||
<p className="mb-1.5 text-xs font-medium text-foreground">{data.day}</p>
|
||
<div className="space-y-1">
|
||
<div className="flex items-center justify-between gap-4">
|
||
<span className={tooltipLabelClass}>سفارشها:</span>
|
||
<span className={tooltipValueClass}>
|
||
{numberFormatter.format(data.orderCount)}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-4">
|
||
<span className={tooltipLabelClass}>میانگین سفارش:</span>
|
||
<span className={tooltipValueClass}>
|
||
{currencyFormatter.format(data.avgOrderValue)}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-4 border-t border-border pt-1">
|
||
<span className={tooltipLabelClass}>درآمد:</span>
|
||
<span className={tooltipValueClass}>
|
||
{currencyFormatter.format(data.totalRevenue)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const AOVScatterChart = () => {
|
||
const totalOrders = dailyMetrics.reduce((s, d) => s + d.orderCount, 0);
|
||
const totalRevenue = dailyMetrics.reduce((s, d) => s + d.totalRevenue, 0);
|
||
const avgAOV = totalOrders === 0 ? 0 : totalRevenue / totalOrders;
|
||
|
||
return (
|
||
<div className="flex min-w-0 flex-1 flex-col rounded-xl border bg-card">
|
||
<div className={chartHeaderClass}>
|
||
<div className="flex items-center gap-2.5">
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className={chartIconBtnClass}
|
||
aria-label="میانگین سفارش در برابر حجم سفارش"
|
||
>
|
||
<BarChart3 className={chartIconClass} aria-hidden="true" />
|
||
</Button>
|
||
<h2 className={chartTitleClass}>میانگین سفارش در برابر حجم</h2>
|
||
</div>
|
||
<span className={tooltipLabelClass}>۳۰ روز اخیر</span>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4 p-4 sm:gap-5 sm:p-5">
|
||
<div className="flex items-center gap-6">
|
||
<div className="flex flex-col gap-1">
|
||
<p className="text-xl leading-tight font-semibold tracking-tight sm:text-xl">
|
||
{currencyFormatter.format(avgAOV)}
|
||
</p>
|
||
<p className="text-[10px] tracking-wider text-muted-foreground uppercase sm:text-xs">
|
||
میانگین سفارش
|
||
</p>
|
||
</div>
|
||
<div className="w-px self-stretch bg-border" />
|
||
<div className="flex flex-col gap-1">
|
||
<p className="text-xl leading-tight font-semibold tracking-tight sm:text-xl">
|
||
{numberFormatter.format(totalOrders)}
|
||
</p>
|
||
<p className="text-[10px] tracking-wider text-muted-foreground uppercase sm:text-xs">
|
||
مجموع سفارشها
|
||
</p>
|
||
</div>
|
||
<div className="w-px self-stretch bg-border" />
|
||
<div className="flex flex-col gap-1">
|
||
<p className="text-xl leading-tight font-semibold tracking-tight sm:text-xl">
|
||
{compactCurrencyFormatter.format(totalRevenue)}
|
||
</p>
|
||
<p className="text-[10px] tracking-wider text-muted-foreground uppercase sm:text-xs">
|
||
درآمد کل
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="h-[240px] w-full min-w-0 sm:h-[280px]">
|
||
<ChartContainer
|
||
config={scatterChartConfig}
|
||
className="aspect-auto h-full w-full"
|
||
>
|
||
<ScatterChart margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
||
<CartesianGrid strokeDasharray="0" />
|
||
<XAxis
|
||
type="number"
|
||
dataKey="orderCount"
|
||
name="سفارشها"
|
||
axisLine={false}
|
||
tickLine={false}
|
||
tick={{ fontSize: 10 }}
|
||
/>
|
||
<YAxis
|
||
type="number"
|
||
dataKey="avgOrderValue"
|
||
name="میانگین سفارش"
|
||
axisLine={false}
|
||
tickLine={false}
|
||
tick={{ fontSize: 10 }}
|
||
width={50}
|
||
tickFormatter={(v) => currencyFormatter.format(v)}
|
||
/>
|
||
<ZAxis
|
||
type="number"
|
||
dataKey="totalRevenue"
|
||
range={[40, 400]}
|
||
name="درآمد"
|
||
/>
|
||
<Tooltip
|
||
content={<ScatterTooltip />}
|
||
cursor={{ strokeDasharray: "3 3" }}
|
||
/>
|
||
<Scatter
|
||
data={dailyMetrics}
|
||
fill="var(--color-scatter)"
|
||
fillOpacity={0.6}
|
||
strokeWidth={0}
|
||
/>
|
||
</ScatterChart>
|
||
</ChartContainer>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
function ChannelTooltip({
|
||
active,
|
||
payload,
|
||
label,
|
||
}: any) {
|
||
if (!active || !payload?.length) return null;
|
||
|
||
const total = payload.reduce(
|
||
(sum: number, entry: any) => sum + Number(entry.value ?? 0),
|
||
0,
|
||
);
|
||
|
||
return (
|
||
<div className="rounded-lg border border-border bg-popover px-3 py-2 shadow-lg">
|
||
<p className="mb-1.5 text-xs font-medium text-foreground">{label}</p>
|
||
<div className="space-y-1">
|
||
{payload.map((entry: any) => {
|
||
const dataKey = String(entry.dataKey ?? "");
|
||
const label =
|
||
channelChartConfig[dataKey as keyof typeof channelChartConfig]
|
||
?.label ?? entry.name ?? dataKey;
|
||
return (
|
||
<div key={`${entry.dataKey ?? entry.name ?? label}`} className="flex items-center gap-2">
|
||
<div
|
||
className="size-2 rounded-full"
|
||
style={{ backgroundColor: String(entry.color) }}
|
||
/>
|
||
<span className={tooltipLabelClass}>{label}:</span>
|
||
<span className={tooltipValueClass}>
|
||
{compactCurrencyFormatter.format(Number(entry.value))}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
<div className="border-t border-border pt-1">
|
||
<span className={tooltipValueClass}>
|
||
مجموع: {compactCurrencyFormatter.format(total)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const ChannelRevenueChart = () => {
|
||
return (
|
||
<div className="flex min-w-0 flex-col rounded-xl border bg-card">
|
||
<div className={chartHeaderClass}>
|
||
<div className="flex items-center gap-2.5">
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className={chartIconBtnClass}
|
||
aria-label="درآمد بر اساس کانال"
|
||
>
|
||
<BarChart3 className={chartIconClass} aria-hidden="true" />
|
||
</Button>
|
||
<h2 className={chartTitleClass}>درآمد بر اساس کانال</h2>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2 p-4 sm:p-5">
|
||
<div className="flex flex-wrap items-center gap-3 sm:gap-5">
|
||
{[
|
||
{ label: "آنلاین", color: palette.primary },
|
||
{ label: "حضوری", color: palette.secondary.light },
|
||
{ label: "عمدهفروشی", color: palette.tertiary.light },
|
||
{ label: "مارکتپلیس", color: palette.quaternary.light },
|
||
].map((item) => (
|
||
<div key={item.label} className="flex items-center gap-1.5">
|
||
<div
|
||
className="size-2 rounded-full sm:size-2.5"
|
||
style={{ backgroundColor: item.color }}
|
||
/>
|
||
<span className={tooltipLabelClass}>{item.label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="h-[240px] w-full min-w-0 sm:h-[280px]">
|
||
<ChartContainer config={channelChartConfig} className="h-full w-full">
|
||
<BarChart
|
||
layout="vertical"
|
||
data={channelRevenueData}
|
||
barSize={24}
|
||
margin={{ top: 0, right: 0, bottom: 0, left: -9 }}
|
||
>
|
||
<XAxis
|
||
type="number"
|
||
axisLine={false}
|
||
tickLine={false}
|
||
tick={{ fontSize: 10 }}
|
||
tickFormatter={(v) => compactCurrencyFormatter.format(v)}
|
||
/>
|
||
<YAxis
|
||
type="category"
|
||
dataKey="month"
|
||
axisLine={false}
|
||
tickLine={false}
|
||
tick={{ fontSize: 10 }}
|
||
width={36}
|
||
/>
|
||
<Tooltip
|
||
content={<ChannelTooltip />}
|
||
cursor={{ fillOpacity: 0.05 }}
|
||
/>
|
||
<Bar
|
||
dataKey="online"
|
||
stackId="channel"
|
||
fill="var(--color-online)"
|
||
radius={[4, 0, 0, 4]}
|
||
/>
|
||
<Bar
|
||
dataKey="inStore"
|
||
stackId="channel"
|
||
fill="var(--color-inStore)"
|
||
radius={[0, 0, 0, 0]}
|
||
/>
|
||
<Bar
|
||
dataKey="wholesale"
|
||
stackId="channel"
|
||
fill="var(--color-wholesale)"
|
||
radius={[0, 0, 0, 0]}
|
||
/>
|
||
<Bar
|
||
dataKey="marketplace"
|
||
stackId="channel"
|
||
fill="var(--color-marketplace)"
|
||
radius={[0, 4, 4, 0]}
|
||
/>
|
||
</BarChart>
|
||
</ChartContainer>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
function RevenueTooltip({
|
||
active,
|
||
payload,
|
||
label,
|
||
}: any) {
|
||
if (!active || !payload?.length) return null;
|
||
|
||
const visiblePayload = payload.filter(
|
||
(entry: any) => entry.dataKey !== "gapUp" && entry.dataKey !== "gapDown",
|
||
);
|
||
|
||
if (!visiblePayload.length) return null;
|
||
|
||
return (
|
||
<div className="rounded-lg border border-border bg-popover px-3 py-2 shadow-lg">
|
||
<p className="mb-1.5 text-xs font-medium text-foreground">{label}</p>
|
||
<div className="space-y-1">
|
||
{visiblePayload.map((entry: any) => {
|
||
const dataKey = String(entry.dataKey ?? "");
|
||
const cfgLabel =
|
||
revenueChartConfig[dataKey as keyof typeof revenueChartConfig]
|
||
?.label ?? dataKey;
|
||
return (
|
||
<div key={`${entry.dataKey ?? cfgLabel}`} className="flex items-center gap-2">
|
||
<div
|
||
className="size-2 rounded-full"
|
||
style={{ backgroundColor: String(entry.color) }}
|
||
/>
|
||
<span className={tooltipLabelClass}>{cfgLabel}:</span>
|
||
<span className={tooltipValueClass}>
|
||
{currencyFormatter.format(Math.abs(Number(entry.value)))}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const RevenueOverviewChart = () => {
|
||
const totalThisYear = fullYearData.reduce(
|
||
(sum, entry) => sum + entry.thisYear,
|
||
0,
|
||
);
|
||
const totalLastYear = fullYearData.reduce(
|
||
(sum, entry) => sum + Math.abs(entry.lastYear),
|
||
0,
|
||
);
|
||
const changePercent =
|
||
totalLastYear === 0
|
||
? 0
|
||
: ((totalThisYear - totalLastYear) / totalLastYear) * 100;
|
||
const isPositive = changePercent >= 0;
|
||
|
||
return (
|
||
<div className="flex min-w-0 flex-1 flex-col rounded-xl border bg-card">
|
||
<div className={chartHeaderClass}>
|
||
<div className="flex items-center gap-2.5">
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className={chartIconBtnClass}
|
||
aria-label="نمای کلی درآمد"
|
||
>
|
||
<BarChart3 className={chartIconClass} aria-hidden="true" />
|
||
</Button>
|
||
<h2 className={chartTitleClass}>نمای کلی درآمد</h2>
|
||
</div>
|
||
<div className="flex items-center gap-3 sm:gap-5">
|
||
{[
|
||
{ label: "امسال", color: palette.primary },
|
||
{ label: "سال قبل", color: palette.secondary.light },
|
||
].map((item) => (
|
||
<div key={item.label} className="flex items-center gap-1.5">
|
||
<div
|
||
className="size-2 rounded-full sm:size-2.5"
|
||
style={{ backgroundColor: item.color }}
|
||
/>
|
||
<span className={tooltipLabelClass}>{item.label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4 p-4 sm:gap-5 sm:p-5">
|
||
<div className="flex flex-col gap-1">
|
||
<p className="text-xl leading-tight font-semibold tracking-tight sm:text-xl">
|
||
{currencyFormatter.format(totalThisYear)}
|
||
</p>
|
||
<p
|
||
className={cn(
|
||
"text-[10px] tracking-wider uppercase sm:text-xs",
|
||
isPositive ? "text-emerald-600" : "text-red-600",
|
||
)}
|
||
>
|
||
{isPositive ? "+" : "-"}
|
||
{Math.abs(changePercent).toLocaleString("fa-IR", {
|
||
maximumFractionDigits: 1,
|
||
})}
|
||
٪ نسبت به سال قبل
|
||
</p>
|
||
</div>
|
||
<div className="h-[240px] w-full min-w-0 sm:h-[280px]">
|
||
<ChartContainer config={revenueChartConfig} className="h-full w-full">
|
||
<BarChart data={fullYearData} stackOffset="sign">
|
||
<CartesianGrid vertical={false} strokeDasharray="0" />
|
||
<XAxis
|
||
dataKey="quarter"
|
||
axisLine={false}
|
||
tickLine={false}
|
||
tick={{ fontSize: 10 }}
|
||
/>
|
||
<YAxis
|
||
orientation="right"
|
||
axisLine={false}
|
||
tickLine={false}
|
||
tick={{ fontSize: 10 }}
|
||
width={50}
|
||
domain={[-revenueDomainMax, revenueDomainMax]}
|
||
tickCount={9}
|
||
tickFormatter={(v: number) =>
|
||
compactCurrencyFormatter.format(Math.abs(v))
|
||
}
|
||
/>
|
||
|
||
<Tooltip
|
||
content={<RevenueTooltip />}
|
||
cursor={{ fillOpacity: 0.05 }}
|
||
/>
|
||
{/* Invisible spacer pushes thisYear bars above zero line */}
|
||
<Bar
|
||
dataKey="gapUp"
|
||
stackId="revenue"
|
||
fill="transparent"
|
||
barSize={22}
|
||
isAnimationActive={false}
|
||
/>
|
||
<Bar
|
||
dataKey="thisYear"
|
||
stackId="revenue"
|
||
fill="var(--color-thisYear)"
|
||
radius={6}
|
||
barSize={22}
|
||
/>
|
||
{/* Invisible spacer pushes lastYear bars below zero line */}
|
||
<Bar
|
||
dataKey="gapDown"
|
||
stackId="revenue"
|
||
fill="transparent"
|
||
barSize={22}
|
||
isAnimationActive={false}
|
||
/>
|
||
<Bar
|
||
dataKey="lastYear"
|
||
stackId="revenue"
|
||
fill="var(--color-lastYear)"
|
||
fillOpacity={0.4}
|
||
radius={6}
|
||
barSize={22}
|
||
/>
|
||
</BarChart>
|
||
</ChartContainer>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ActivityFeed = () => {
|
||
return (
|
||
<div className="flex flex-col rounded-xl border bg-card">
|
||
<div className={chartHeaderClass}>
|
||
<div className="flex items-center gap-2.5">
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className={chartIconBtnClass}
|
||
aria-label="فعالیت تیمهای اخیر"
|
||
>
|
||
<Bell className={chartIconClass} aria-hidden="true" />
|
||
</Button>
|
||
<h2 className={chartTitleClass}>فعالیت تیمهای اخیر</h2>
|
||
</div>
|
||
</div>
|
||
|
||
<ScrollArea className="h-[340px] sm:h-[370px]">
|
||
<div className="divide-y px-4 sm:px-5">
|
||
{activityEvents.map((event) => {
|
||
const Icon = activityIcons[event.type];
|
||
const colorClass = activityColors[event.type];
|
||
|
||
return (
|
||
<div key={event.id} className="flex items-start gap-3 py-3">
|
||
<div
|
||
className={cn(
|
||
"mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full",
|
||
colorClass,
|
||
)}
|
||
>
|
||
<Icon className="size-3.5" aria-hidden="true" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-xs text-foreground sm:text-sm">
|
||
{event.message}
|
||
</p>
|
||
<p className="mt-0.5 text-[10px] text-muted-foreground sm:text-xs">
|
||
{shortDateFormatter.format(event.timestamp)},{" "}
|
||
{timeFormatter.format(event.timestamp)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const RecentOrdersTable = () => {
|
||
const [searchQuery, setSearchQuery] = React.useState("");
|
||
const [statusFilter, setStatusFilter] = React.useState<OrderStatus | "all">(
|
||
"all",
|
||
);
|
||
const [currentPage, setCurrentPage] = React.useState(1);
|
||
const [pageSize, setPageSize] = React.useState(10);
|
||
const [isHydrated, setIsHydrated] = React.useState(false);
|
||
|
||
// Hydrate from URL on mount
|
||
React.useEffect(() => {
|
||
if (typeof window === "undefined") return;
|
||
const params = new URLSearchParams(window.location.search);
|
||
|
||
const q = params.get("q");
|
||
if (q) setSearchQuery(q);
|
||
|
||
const nextStatus = params.get("status");
|
||
if (
|
||
nextStatus &&
|
||
(nextStatus === "all" ||
|
||
orderStatuses.includes(nextStatus as OrderStatus))
|
||
) {
|
||
setStatusFilter(nextStatus as OrderStatus | "all");
|
||
}
|
||
|
||
const nextPage = Number(params.get("page"));
|
||
if (!Number.isNaN(nextPage) && nextPage > 0) {
|
||
setCurrentPage(nextPage);
|
||
}
|
||
|
||
const nextPageSize = Number(params.get("pageSize"));
|
||
if (PAGE_SIZE_OPTIONS.includes(nextPageSize)) {
|
||
setPageSize(nextPageSize);
|
||
}
|
||
|
||
setIsHydrated(true);
|
||
}, []);
|
||
|
||
const filteredOrders = React.useMemo(() => {
|
||
return orders.filter((order) => {
|
||
const matchesSearch =
|
||
order.customer.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
order.orderNumber.toLowerCase().includes(searchQuery.toLowerCase());
|
||
const matchesStatus =
|
||
statusFilter === "all" || order.status === statusFilter;
|
||
return matchesSearch && matchesStatus;
|
||
});
|
||
}, [searchQuery, statusFilter]);
|
||
|
||
const totalPages = Math.max(1, Math.ceil(filteredOrders.length / pageSize));
|
||
|
||
const paginatedOrders = React.useMemo(() => {
|
||
const startIndex = (currentPage - 1) * pageSize;
|
||
return filteredOrders.slice(startIndex, startIndex + pageSize);
|
||
}, [filteredOrders, currentPage, pageSize]);
|
||
|
||
// Reset page when filters change
|
||
React.useEffect(() => {
|
||
setCurrentPage(1);
|
||
}, [searchQuery, statusFilter, pageSize]);
|
||
|
||
// Sync state to URL
|
||
React.useEffect(() => {
|
||
if (!isHydrated || typeof window === "undefined") return;
|
||
const params = new URLSearchParams(window.location.search);
|
||
|
||
if (searchQuery) {
|
||
params.set("q", searchQuery);
|
||
} else {
|
||
params.delete("q");
|
||
}
|
||
|
||
if (statusFilter !== "all") {
|
||
params.set("status", statusFilter);
|
||
} else {
|
||
params.delete("status");
|
||
}
|
||
|
||
if (currentPage > 1) {
|
||
params.set("page", String(currentPage));
|
||
} else {
|
||
params.delete("page");
|
||
}
|
||
|
||
if (pageSize !== PAGE_SIZE_OPTIONS[0]) {
|
||
params.set("pageSize", String(pageSize));
|
||
} else {
|
||
params.delete("pageSize");
|
||
}
|
||
|
||
const nextQuery = params.toString();
|
||
const nextUrl = nextQuery
|
||
? `${window.location.pathname}?${nextQuery}`
|
||
: window.location.pathname;
|
||
window.history.replaceState(null, "", nextUrl);
|
||
}, [searchQuery, statusFilter, currentPage, pageSize, isHydrated]);
|
||
|
||
const goToPage = (page: number) => {
|
||
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
|
||
};
|
||
|
||
const startRow = filteredOrders.length ? (currentPage - 1) * pageSize + 1 : 0;
|
||
const endRow = Math.min(currentPage * pageSize, filteredOrders.length);
|
||
|
||
return (
|
||
<div className="rounded-xl border bg-card">
|
||
<div className="flex flex-col gap-3 px-4 pt-4 sm:flex-row sm:items-center sm:gap-4 sm:px-6">
|
||
<div className="flex items-center gap-2">
|
||
<h2 className={chartTitleClass}>سفارشهای اخیر</h2>
|
||
<span className="ms-1 inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-[10px] font-medium text-gray-600 ring-1 ring-gray-500/10 ring-inset sm:text-xs dark:bg-gray-800/50 dark:text-gray-400 dark:ring-gray-400/20">
|
||
{filteredOrders.length.toLocaleString("fa-IR")}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex flex-1 flex-wrap items-center gap-2 sm:justify-end">
|
||
<div className="relative flex-1 sm:flex-none">
|
||
<Search
|
||
className="absolute top-1/2 start-3 size-4 -translate-y-1/2 text-muted-foreground"
|
||
aria-hidden="true"
|
||
/>
|
||
<Input
|
||
placeholder="جستجوی سفارشها…"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
name="order-search"
|
||
inputMode="search"
|
||
autoComplete="off"
|
||
aria-label="جستجوی سفارشها"
|
||
className="h-8 w-full ps-9 text-sm sm:h-9 sm:w-[200px]"
|
||
/>
|
||
</div>
|
||
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 gap-1.5 sm:h-9 sm:gap-2"
|
||
aria-label="فیلتر بر اساس وضعیت"
|
||
>
|
||
<span className="text-xs sm:text-sm">
|
||
{statusFilter === "all"
|
||
? "همه وضعیتها"
|
||
: orderStatusLabels[statusFilter]}
|
||
</span>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-[180px]">
|
||
<DropdownMenuLabel>فیلتر بر اساس وضعیت</DropdownMenuLabel>
|
||
<DropdownMenuCheckboxItem
|
||
checked={statusFilter === "all"}
|
||
onCheckedChange={() => setStatusFilter("all")}
|
||
>
|
||
همه وضعیتها
|
||
</DropdownMenuCheckboxItem>
|
||
{orderStatuses.map((status) => (
|
||
<DropdownMenuCheckboxItem
|
||
key={status}
|
||
checked={statusFilter === status}
|
||
onCheckedChange={() => setStatusFilter(status)}
|
||
>
|
||
{orderStatusLabels[status]}
|
||
</DropdownMenuCheckboxItem>
|
||
))}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="px-4 pt-3 pb-4 sm:px-6">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||
<TableHead className="text-xs font-medium text-muted-foreground sm:text-sm">
|
||
مرجع سفارش
|
||
</TableHead>
|
||
<TableHead className="text-xs font-medium text-muted-foreground sm:text-sm">
|
||
مشتری
|
||
</TableHead>
|
||
<TableHead className="hidden text-xs font-medium text-muted-foreground sm:table-cell sm:text-sm">
|
||
تاریخ
|
||
</TableHead>
|
||
<TableHead className="text-xs font-medium text-muted-foreground sm:text-sm">
|
||
مجموع
|
||
</TableHead>
|
||
<TableHead className="text-xs font-medium text-muted-foreground sm:text-sm">
|
||
وضعیت
|
||
</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{paginatedOrders.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell
|
||
colSpan={5}
|
||
className="h-20 text-center text-sm text-muted-foreground"
|
||
>
|
||
سفارشی یافت نشد.
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
paginatedOrders.map((order) => (
|
||
<TableRow key={order.id}>
|
||
<TableCell className="text-xs font-medium text-muted-foreground sm:text-sm">
|
||
{order.orderNumber}
|
||
</TableCell>
|
||
<TableCell className="text-xs text-foreground sm:text-sm">
|
||
{order.customer}
|
||
</TableCell>
|
||
<TableCell className="hidden text-xs text-muted-foreground sm:table-cell sm:text-sm">
|
||
{shortDateFormatter.format(new Date(order.date))}
|
||
</TableCell>
|
||
<TableCell className="text-xs text-foreground tabular-nums sm:text-sm">
|
||
{currencyFormatter.format(order.total)}
|
||
</TableCell>
|
||
<TableCell>
|
||
<span
|
||
className={cn(
|
||
"inline-flex items-center rounded-md px-2 py-1 text-[10px] font-medium sm:text-xs",
|
||
statusStyles[order.status],
|
||
)}
|
||
>
|
||
{orderStatusLabels[order.status]}
|
||
</span>
|
||
</TableCell>
|
||
</TableRow>
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
<div className="flex flex-col items-center justify-between gap-3 border-t px-4 py-3 sm:flex-row sm:px-6">
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground sm:text-sm">
|
||
<span className="hidden sm:inline">ردیف در هر صفحه:</span>
|
||
<Select
|
||
value={pageSize.toString()}
|
||
onValueChange={(value) => setPageSize(Number(value))}
|
||
>
|
||
<SelectTrigger className="h-8 w-[70px]" aria-label="ردیف در هر صفحه">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||
<SelectItem key={size} value={size.toString()}>
|
||
{size.toLocaleString("fa-IR")}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<span>
|
||
{filteredOrders.length === 0
|
||
? "۰"
|
||
: `${startRow.toLocaleString("fa-IR")}-${endRow.toLocaleString("fa-IR")}`}{" "}
|
||
از {filteredOrders.length.toLocaleString("fa-IR")}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className="size-7"
|
||
onClick={() => goToPage(currentPage - 1)}
|
||
disabled={currentPage === 1}
|
||
aria-label="رفتن به صفحه قبل"
|
||
>
|
||
<ChevronLeft className="size-3.5 rtl:rotate-180" aria-hidden="true" />
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className="size-7"
|
||
onClick={() => goToPage(currentPage + 1)}
|
||
disabled={currentPage === totalPages}
|
||
aria-label="رفتن به صفحه بعد"
|
||
>
|
||
<ChevronRight className="size-3.5 rtl:rotate-180" aria-hidden="true" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const DashboardContent = () => {
|
||
return (
|
||
<div
|
||
id="dashboard-main"
|
||
tabIndex={-1}
|
||
className="mx-auto w-full max-w-7xl flex-1 space-y-4 overflow-auto bg-background p-3 sm:space-y-6 sm:p-4 md:p-6"
|
||
>
|
||
<DashboardHeader />
|
||
<StatCards />
|
||
<div className="grid gap-4 sm:gap-6 lg:grid-cols-[3fr_2fr]">
|
||
<RevenueOverviewChart />
|
||
<AOVScatterChart />
|
||
</div>
|
||
<div className="grid gap-4 sm:gap-6 lg:grid-cols-[2fr_1fr]">
|
||
<ChannelRevenueChart />
|
||
<ActivityFeed />
|
||
</div>
|
||
<RecentOrdersTable />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const getDefaultActiveItem = (group: NavGroup): NavItem | null => {
|
||
return group.items.find((item) => item.isActive) || group.items[0] || null;
|
||
};
|
||
|
||
const Dashboard10 = ({ className }: { className?: string }) => {
|
||
const [activeGroupIndex, setActiveGroupIndex] = React.useState(0);
|
||
const activeGroup = shellData.navGroups[activeGroupIndex];
|
||
|
||
const [activeItem, setActiveItem] = React.useState<NavItem | null>(
|
||
getDefaultActiveItem(activeGroup),
|
||
);
|
||
|
||
// Update activeItem when group changes
|
||
React.useEffect(() => {
|
||
setActiveItem(getDefaultActiveItem(activeGroup));
|
||
}, [activeGroupIndex]);
|
||
|
||
return (
|
||
<div
|
||
dir="rtl"
|
||
lang="fa"
|
||
className={cn("flex min-h-svh flex-col bg-background", className)}
|
||
>
|
||
<a
|
||
href="#dashboard-main"
|
||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:start-4 focus:z-50 focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:text-foreground focus:ring-2 focus:ring-ring"
|
||
>
|
||
رفتن به محتوای اصلی
|
||
</a>
|
||
|
||
<header className="sticky top-0 z-50 bg-background">
|
||
<div className="border-b">
|
||
<div className="mx-auto flex h-14 w-full max-w-7xl items-center gap-4 px-4 lg:px-6">
|
||
<MobileNav
|
||
activeGroupIndex={activeGroupIndex}
|
||
onSelectGroup={setActiveGroupIndex}
|
||
/>
|
||
|
||
<a href="#" className="flex items-center gap-2">
|
||
<div className="flex aspect-square size-8 items-center justify-center rounded-sm bg-primary">
|
||
<img
|
||
src={shellData.logo.src}
|
||
alt={shellData.logo.alt}
|
||
className="size-6 text-primary-foreground invert dark:invert-0"
|
||
/>
|
||
</div>
|
||
<span className="font-semibold">{shellData.logo.title}</span>
|
||
</a>
|
||
|
||
<nav
|
||
className="ms-4 hidden items-center gap-1 md:flex"
|
||
aria-label="ناوبری اصلی"
|
||
>
|
||
{shellData.navGroups.map((group, index) => (
|
||
<NavDropdown
|
||
key={group.title}
|
||
group={group}
|
||
isActive={index === activeGroupIndex}
|
||
onSelect={() => setActiveGroupIndex(index)}
|
||
/>
|
||
))}
|
||
</nav>
|
||
|
||
<div className="ms-auto flex items-center gap-2">
|
||
<div className="relative hidden md:block">
|
||
<Search
|
||
className="absolute top-1/2 start-2.5 size-4 -translate-y-1/2 text-muted-foreground"
|
||
aria-hidden="true"
|
||
/>
|
||
<Input
|
||
type="search"
|
||
placeholder="جستجو…"
|
||
className="h-9 w-64 ps-8"
|
||
aria-label="جستجو"
|
||
/>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="md:hidden"
|
||
aria-label="جستجو"
|
||
>
|
||
<Search className="size-5" aria-hidden="true" />
|
||
</Button>
|
||
<NavUser user={shellData.user} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<SubNav
|
||
group={activeGroup}
|
||
activeItem={activeItem}
|
||
onSelectItem={setActiveItem}
|
||
/>
|
||
</header>
|
||
|
||
<DashboardContent />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export { Dashboard10 };
|