Files

2305 lines
73 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
ArrowUpDown,
BarChart3,
Bell,
Box,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
ChevronsUpDown,
ClipboardList,
Download,
Filter,
Globe,
Grid3x3,
HelpCircle,
LayoutDashboard,
LogOut,
MessageSquare,
Minus,
Package,
RotateCcw,
Search,
Settings,
TrendingDown,
TrendingUp,
Truck,
User,
Users,
Wallet,
X,
} from "lucide-react";
import * as React from "react";
import type { TooltipProps } from "recharts";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Pie,
PieChart,
PolarAngleAxis,
PolarGrid,
Radar,
RadarChart,
Sector,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { PieSectorDataItem } from "recharts/types/polar/Pie";
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,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarTrigger,
} from "@/components/ui/sidebar";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip as ShadTooltip,
TooltipContent as ShadTooltipContent,
TooltipProvider as ShadTooltipProvider,
TooltipTrigger as ShadTooltipTrigger,
} from "@/components/ui/tooltip";
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[];
defaultOpen?: boolean;
};
type UserData = {
name: string;
email: string;
avatar: string;
};
type SidebarData = {
logo: {
src: string;
alt: string;
title: string;
description: string;
};
navGroups: NavGroup[];
footerGroup: NavGroup;
user?: UserData;
};
type InvoicePriority = "High" | "Medium" | "Low";
type InvoiceStatus = "Paid" | "Pending" | "Overdue" | "Draft";
type Invoice = {
id: string;
invoiceId: string;
customer: string;
customerInitials: string;
date: string;
amount: number;
priority: InvoicePriority;
status: InvoiceStatus;
};
type InvoiceSortKey = "customer" | "invoiceId" | "date" | "amount";
type RichMetricStatItem = {
title: string;
value: number;
trendValue: number;
footerDelta: number;
footerSubtextCount: number;
};
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 compactCurrencyOneDecimalFormatter = new Intl.NumberFormat("fa-IR", {
style: "currency",
currency: "IRR",
notation: "compact",
maximumFractionDigits: 1,
});
const invoiceDateFormatter = new Intl.DateTimeFormat("fa-IR", {
year: "numeric",
month: "short",
day: "numeric",
});
const numberFormatter = new Intl.NumberFormat("fa-IR");
const weekdayFormatter = new Intl.DateTimeFormat("fa-IR", { weekday: "short" });
const monthFormatter = new Intl.DateTimeFormat("fa-IR", { month: "short" });
const dashboardRangeFormatter = new Intl.DateTimeFormat("fa-IR", {
month: "short",
day: "numeric",
year: "numeric",
});
const dashboardRangeStart = new Date(2025, 0, 1);
const dashboardRangeEnd = new Date(2025, 0, 31);
const dashboardDateRangeLabel =
typeof dashboardRangeFormatter.formatRange === "function"
? dashboardRangeFormatter.formatRange(
dashboardRangeStart,
dashboardRangeEnd,
)
: `${dashboardRangeFormatter.format(
dashboardRangeStart,
)} – ${dashboardRangeFormatter.format(dashboardRangeEnd)}`;
const invoiceStatusLabels: Record<InvoiceStatus, string> = {
Paid: "پرداخت‌شده",
Pending: "در انتظار",
Overdue: "سررسید گذشته",
Draft: "پیش‌نویس",
};
const invoicePriorityLabels: Record<InvoicePriority, string> = {
High: "بالا",
Medium: "متوسط",
Low: "پایین",
};
const mixBase = "var(--background)";
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 sidebarData: SidebarData = {
logo: {
src: "/block-media/shadcnblocks/block/logos/NovaSales-logo.svg",
alt: "فروشگاه آکمه",
title: "ری یو آی Store",
description: "فروشگاه آنلاین",
},
navGroups: [
{
title: "اصلی",
defaultOpen: true,
items: [
{
label: "داشبورد",
icon: LayoutDashboard,
href: "#",
isActive: true,
},
{ label: "سفارش‌ها", icon: ClipboardList, href: "#" },
{ label: "مرجوعی‌ها", icon: RotateCcw, href: "#" },
],
},
{
title: "کاتالوگ",
defaultOpen: true,
items: [
{
label: "محصولات",
icon: Box,
href: "#",
children: [
{ label: "همه محصولات", icon: Package, href: "#" },
{ label: "دسته‌بندی‌ها", icon: Package, href: "#" },
{ label: "موجودی", icon: Package, href: "#" },
],
},
{ label: "ارسال", icon: Truck, href: "#" },
],
},
{
title: "مشتریان",
defaultOpen: false,
items: [
{ label: "همه مشتریان", icon: Users, href: "#" },
{ label: "پیام‌ها", icon: MessageSquare, href: "#" },
],
},
{
title: "تحلیل فروش",
defaultOpen: false,
items: [
{ label: "نمای کلی", icon: Globe, href: "#" },
{ label: "گزارش‌ها", icon: BarChart3, href: "#" },
{ label: "امور مالی", icon: Wallet, href: "#" },
],
},
],
footerGroup: {
title: "تنظیمات",
items: [{ label: "تنظیمات", icon: Settings, href: "#" }],
},
user: {
name: "علی رضایی",
email: "john@acme.store",
avatar:
"/block-media/shadcnblocks/block/avatar-6.webp",
},
};
const richMetricStats: RichMetricStatItem[] = [
{
title: "فروش حضوری",
value: 7820.75,
trendValue: 4.3,
footerDelta: 322.5,
footerSubtextCount: 5000,
},
{
title: "فروش آنلاین",
value: 985_937.45,
trendValue: 12.5,
footerDelta: 109_500,
footerSubtextCount: 21_000,
},
{
title: "عمده‌فروشی",
value: 124_650,
trendValue: 6.8,
footerDelta: 7_935,
footerSubtextCount: 890,
},
{
title: "مرجوعی‌ها",
value: 15_503,
trendValue: -3.1,
footerDelta: -498,
footerSubtextCount: 6000,
},
];
const weeklyRevenueData = [
{
day: weekdayFormatter.format(new Date(2025, 0, 6)),
revenue: 1200,
expenses: 800,
},
{
day: weekdayFormatter.format(new Date(2025, 0, 7)),
revenue: 1500,
expenses: 600,
},
{
day: weekdayFormatter.format(new Date(2025, 0, 8)),
revenue: 1800,
expenses: 1100,
},
{
day: weekdayFormatter.format(new Date(2025, 0, 9)),
revenue: 1100,
expenses: 900,
},
{
day: weekdayFormatter.format(new Date(2025, 0, 10)),
revenue: 1600,
expenses: 1200,
},
{
day: weekdayFormatter.format(new Date(2025, 0, 11)),
revenue: 800,
expenses: 400,
},
{
day: weekdayFormatter.format(new Date(2025, 0, 12)),
revenue: 900,
expenses: 500,
},
];
const revenueChartConfig = {
revenue: { label: "درآمد", color: palette.primary },
expenses: { label: "هزینه", theme: palette.secondary },
} satisfies ChartConfig;
const monthLabel = (monthIndex: number) =>
monthFormatter.format(new Date(2025, monthIndex, 1));
const salesPipelineData: Record<
string,
{ week: string; month: string; orders: number; sales: number }[]
> = {
q1: [
{ week: "W1", month: monthLabel(0), orders: 220, sales: 5100 },
{ week: "W2", month: monthLabel(0), orders: 480, sales: 11200 },
{ week: "W3", month: monthLabel(0), orders: 390, sales: 9400 },
{ week: "W4", month: monthLabel(0), orders: 150, sales: 3600 },
{ week: "W5", month: monthLabel(1), orders: 310, sales: 7400 },
{ week: "W6", month: monthLabel(1), orders: 540, sales: 13100 },
{ week: "W7", month: monthLabel(1), orders: 460, sales: 10800 },
{ week: "W8", month: monthLabel(1), orders: 200, sales: 4700 },
{ week: "W9", month: monthLabel(2), orders: 130, sales: 3100 },
{ week: "W10", month: monthLabel(2), orders: 420, sales: 10200 },
{ week: "W11", month: monthLabel(2), orders: 510, sales: 12400 },
{ week: "W12", month: monthLabel(2), orders: 350, sales: 8500 },
],
q2: [
{ week: "W1", month: monthLabel(3), orders: 410, sales: 9800 },
{ week: "W2", month: monthLabel(3), orders: 280, sales: 6700 },
{ week: "W3", month: monthLabel(3), orders: 120, sales: 2900 },
{ week: "W4", month: monthLabel(3), orders: 350, sales: 8400 },
{ week: "W5", month: monthLabel(4), orders: 520, sales: 12600 },
{ week: "W6", month: monthLabel(4), orders: 470, sales: 11300 },
{ week: "W7", month: monthLabel(4), orders: 190, sales: 4500 },
{ week: "W8", month: monthLabel(4), orders: 100, sales: 2400 },
{ week: "W9", month: monthLabel(5), orders: 330, sales: 7900 },
{ week: "W10", month: monthLabel(5), orders: 490, sales: 11800 },
{ week: "W11", month: monthLabel(5), orders: 540, sales: 13000 },
{ week: "W12", month: monthLabel(5), orders: 260, sales: 6200 },
],
q3: [
{ week: "W1", month: monthLabel(6), orders: 180, sales: 4200 },
{ week: "W2", month: monthLabel(6), orders: 520, sales: 12800 },
{ week: "W3", month: monthLabel(6), orders: 480, sales: 11500 },
{ week: "W4", month: monthLabel(6), orders: 120, sales: 2800 },
{ week: "W5", month: monthLabel(7), orders: 90, sales: 2100 },
{ week: "W6", month: monthLabel(7), orders: 450, sales: 10500 },
{ week: "W7", month: monthLabel(7), orders: 510, sales: 12200 },
{ week: "W8", month: monthLabel(7), orders: 480, sales: 11000 },
{ week: "W9", month: monthLabel(8), orders: 200, sales: 4800 },
{ week: "W10", month: monthLabel(8), orders: 150, sales: 3500 },
{ week: "W11", month: monthLabel(8), orders: 380, sales: 9200 },
{ week: "W12", month: monthLabel(8), orders: 420, sales: 10100 },
],
q4: [
{ week: "W1", month: monthLabel(9), orders: 300, sales: 7200 },
{ week: "W2", month: monthLabel(9), orders: 160, sales: 3800 },
{ week: "W3", month: monthLabel(9), orders: 440, sales: 10600 },
{ week: "W4", month: monthLabel(9), orders: 530, sales: 12900 },
{ week: "W5", month: monthLabel(10), orders: 380, sales: 9100 },
{ week: "W6", month: monthLabel(10), orders: 140, sales: 3400 },
{ week: "W7", month: monthLabel(10), orders: 250, sales: 6000 },
{ week: "W8", month: monthLabel(10), orders: 500, sales: 12100 },
{ week: "W9", month: monthLabel(11), orders: 550, sales: 13300 },
{ week: "W10", month: monthLabel(11), orders: 470, sales: 11400 },
{ week: "W11", month: monthLabel(11), orders: 210, sales: 5000 },
{ week: "W12", month: monthLabel(11), orders: 340, sales: 8200 },
],
};
const pipelineOrdersConfig = {
orders: { label: "سفارش‌ها", color: palette.primary },
} satisfies ChartConfig;
const pipelineSalesConfig = {
sales: { label: "فروش", theme: palette.tertiary },
} satisfies ChartConfig;
const incomeCategoryData = [
{ category: "الکترونیک", value: 8200 },
{ category: "پوشاک", value: 5400 },
{ category: "خانه و باغ", value: 3800 },
{ category: "ورزشی", value: 4100 },
{ category: "زیبایی", value: 2900 },
];
const radarChartConfig = {
value: { label: "فروش", color: palette.primary },
} satisfies ChartConfig;
type HeatmapCell = {
value: number;
isCurrentWeek: boolean;
};
type TrafficSource = {
name: string;
total: string;
weeks: HeatmapCell[];
};
const trafficSourcesData: TrafficSource[] = [
{
name: "",
total: "6k",
weeks: [
{ value: 0, isCurrentWeek: false },
{ value: 0, isCurrentWeek: false },
{ value: 0, isCurrentWeek: false },
{ value: 0, isCurrentWeek: false },
],
},
{
name: "تبلیغات گوگل",
total: "5k",
weeks: [
{ value: 0.5, isCurrentWeek: false },
{ value: 0.95, isCurrentWeek: true },
{ value: 0.2, isCurrentWeek: false },
{ value: 0.55, isCurrentWeek: false },
],
},
{
name: "فیسبوک",
total: "4k",
weeks: [
{ value: 0.25, isCurrentWeek: false },
{ value: 0.6, isCurrentWeek: false },
{ value: 0.15, isCurrentWeek: false },
{ value: 0.5, isCurrentWeek: false },
],
},
{
name: "ارگانیک",
total: "2k",
weeks: [
{ value: 0.2, isCurrentWeek: false },
{ value: 0.9, isCurrentWeek: true },
{ value: 0.1, isCurrentWeek: false },
{ value: 0.15, isCurrentWeek: false },
],
},
{
name: "ایمیل",
total: "3k",
weeks: [
{ value: 0.45, isCurrentWeek: false },
{ value: 0.95, isCurrentWeek: true },
{ value: 0.85, isCurrentWeek: true },
{ value: 0.2, isCurrentWeek: false },
],
},
{
name: "مستقیم",
total: "4.5k",
weeks: [
{ value: 0.4, isCurrentWeek: false },
{ value: 0.9, isCurrentWeek: true },
{ value: 0.8, isCurrentWeek: true },
{ value: 0.25, isCurrentWeek: false },
],
},
];
const weekLabels = ["هفته ۱", "هفته ۲", "هفته ۳", "هفته ۴"];
const orderStatIRRata = [
{ key: "fulfilled", label: "تکمیل‌شده", value: 3200 },
{ key: "pending", label: "در انتظار", value: 850 },
];
const orderStatusChartConfig = {
fulfilled: { label: "تکمیل‌شده", color: palette.primary },
pending: { label: "در انتظار", theme: palette.tertiary },
} satisfies ChartConfig;
const invoiceStatuses: InvoiceStatus[] = [
"Paid",
"Pending",
"Overdue",
"Draft",
];
const INVOICE_PAGE_SIZE_OPTIONS = [5, 10, 20];
const invoices: Invoice[] = [
{
id: "1",
invoiceId: "INV-00001",
customer: "James Brown",
customerInitials: "JB",
date: "2025-01-05",
amount: 1250.0,
priority: "High",
status: "Paid",
},
{
id: "2",
invoiceId: "INV-00002",
customer: "Sarah Miller",
customerInitials: "SM",
date: "2025-01-08",
amount: 3420.5,
priority: "Medium",
status: "Pending",
},
{
id: "3",
invoiceId: "INV-00003",
customer: "Michael Chen",
customerInitials: "MC",
date: "2025-01-12",
amount: 890.0,
priority: "Low",
status: "Paid",
},
{
id: "4",
invoiceId: "INV-00004",
customer: "Emily Davis",
customerInitials: "ED",
date: "2025-01-15",
amount: 4750.0,
priority: "High",
status: "Overdue",
},
{
id: "5",
invoiceId: "INV-00005",
customer: "Robert Wilson",
customerInitials: "RW",
date: "2025-01-18",
amount: 567.25,
priority: "Low",
status: "Draft",
},
{
id: "6",
invoiceId: "INV-00006",
customer: "Lisa Anderson",
customerInitials: "LA",
date: "2025-01-22",
amount: 2100.0,
priority: "Medium",
status: "Paid",
},
{
id: "7",
invoiceId: "INV-00007",
customer: "David Martinez",
customerInitials: "DM",
date: "2025-02-01",
amount: 1875.75,
priority: "High",
status: "Pending",
},
{
id: "8",
invoiceId: "INV-00008",
customer: "Jennifer Taylor",
customerInitials: "JT",
date: "2025-02-05",
amount: 45.0,
priority: "Low",
status: "Paid",
},
{
id: "9",
invoiceId: "INV-00009",
customer: "Chris Thompson",
customerInitials: "CT",
date: "2025-02-10",
amount: 3200.0,
priority: "Medium",
status: "Overdue",
},
{
id: "10",
invoiceId: "INV-00010",
customer: "Amanda White",
customerInitials: "AW",
date: "2025-02-14",
amount: 980.5,
priority: "High",
status: "Pending",
},
{
id: "11",
invoiceId: "INV-00011",
customer: "Kevin Harris",
customerInitials: "KH",
date: "2025-02-18",
amount: 4200.0,
priority: "Medium",
status: "Paid",
},
{
id: "12",
invoiceId: "INV-00012",
customer: "Rachel Clark",
customerInitials: "RC",
date: "2025-02-22",
amount: 1560.0,
priority: "Low",
status: "Draft",
},
{
id: "13",
invoiceId: "INV-00013",
customer: "Thomas Lewis",
customerInitials: "TL",
date: "2025-03-01",
amount: 2890.0,
priority: "High",
status: "Overdue",
},
{
id: "14",
invoiceId: "INV-00014",
customer: "Maria Robinson",
customerInitials: "MR",
date: "2025-03-05",
amount: 725.0,
priority: "Low",
status: "Paid",
},
{
id: "15",
invoiceId: "INV-00015",
customer: "Daniel Walker",
customerInitials: "DW",
date: "2025-03-10",
amount: 5000.0,
priority: "High",
status: "Pending",
},
{
id: "16",
invoiceId: "INV-00016",
customer: "Sophie Hall",
customerInitials: "SH",
date: "2025-03-14",
amount: 1340.0,
priority: "Medium",
status: "Paid",
},
{
id: "17",
invoiceId: "INV-00017",
customer: "Nathan Young",
customerInitials: "NY",
date: "2025-03-18",
amount: 2675.5,
priority: "Medium",
status: "Draft",
},
{
id: "18",
invoiceId: "INV-00018",
customer: "Olivia King",
customerInitials: "OK",
date: "2025-03-22",
amount: 460.0,
priority: "Low",
status: "Pending",
},
];
const badgeClassName =
"inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ring-1 ring-inset";
const invoiceStatusStyles: Record<InvoiceStatus, string> = {
Paid: "ring-emerald-500/30 text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10",
Pending:
"ring-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10",
Overdue:
"ring-red-500/30 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-500/10",
Draft:
"ring-zinc-500/30 text-zinc-600 dark:text-zinc-400 bg-zinc-100 dark:bg-zinc-500/10",
};
const invoicePriorityStyles: Record<InvoicePriority, string> = {
High: "ring-red-500/30 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-500/10",
Medium:
"ring-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10",
Low: "ring-zinc-500/30 text-zinc-600 dark:text-zinc-400 bg-zinc-100 dark:bg-zinc-500/10",
};
const SidebarLogo = ({ logo }: { logo: SidebarData["logo"] }) => {
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" tooltip={logo.title}>
<div className="flex aspect-square size-8 items-center justify-center rounded-sm bg-primary">
<img
src={logo.src}
alt={logo.alt}
width={24}
height={24}
className="size-6 text-primary-foreground invert dark:invert-0"
/>
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-medium">{logo.title}</span>
<span className="text-xs text-muted-foreground">
{logo.description}
</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
);
};
const NavMenuItem = ({ item }: { item: NavItem }) => {
const Icon = item.icon;
const hasChildren = item.children && item.children.length > 0;
if (!hasChildren) {
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={item.isActive}
tooltip={item.label}
>
<a href={item.href}>
<Icon className="size-4" aria-hidden="true" />
<span>{item.label}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
return (
<Collapsible asChild defaultOpen className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton isActive={item.isActive} tooltip={item.label}>
<Icon className="size-4" aria-hidden="true" />
<span>{item.label}</span>
<ChevronRight
className="ms-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 rtl:rotate-180"
aria-hidden="true"
/>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.children!.map((child) => (
<SidebarMenuSubItem key={child.label}>
<SidebarMenuSubButton asChild isActive={child.isActive}>
<a href={child.href}>{child.label}</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
};
const NavUser = ({ user }: { user: UserData }) => {
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="size-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">
{user.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-start text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs text-muted-foreground">
{user.email}
</span>
</div>
<ChevronsUpDown className="ms-auto size-4" aria-hidden="true" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side="bottom"
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<Avatar className="size-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">
{user.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-start text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs text-muted-foreground">
{user.email}
</span>
</div>
</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>
</SidebarMenuItem>
</SidebarMenu>
);
};
const AppSidebar = ({ ...props }: React.ComponentProps<typeof Sidebar>) => {
return (
<Sidebar side="right" variant="inset" collapsible="icon" {...props}>
<SidebarHeader>
<div className="flex items-center gap-2 group-data-[collapsible=icon]:flex-col">
<SidebarLogo logo={sidebarData.logo} />
<SidebarTrigger className="ms-auto group-data-[collapsible=icon]:ms-0" />
</div>
</SidebarHeader>
<SidebarContent>
{sidebarData.navGroups.map((group) => (
<SidebarGroup key={group.title}>
<SidebarGroupLabel>{group.title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => (
<NavMenuItem key={item.label} item={item} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter>
{sidebarData.user && <NavUser user={sidebarData.user} />}
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
};
const DashboardHeader = () => {
return (
<header className="flex w-full items-center gap-3 border-b bg-background px-4 py-4 sm:px-6">
<div className="flex flex-col gap-0.5">
<h1 className="text-base font-medium text-pretty">
نمای کلی داشبورد
</h1>
<p className="hidden text-xs text-muted-foreground sm:block">
خلاصه‌ای از خریدها، فروش‌ها و سایر شاخص‌ها
</p>
</div>
<div className="ms-auto flex items-center gap-2">
<span className="hidden text-xs text-muted-foreground lg:inline">
{dashboardDateRangeLabel}
</span>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
aria-label="همه پلتفرم‌ها"
>
<Globe className="size-3.5" aria-hidden="true" />
<span className="hidden sm:inline">همه پلتفرم‌ها</span>
</Button>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
aria-label="همه محصولات"
>
<span className="hidden sm:inline">همه محصولات</span>
</Button>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
aria-label="خروجی"
>
<Download className="size-3.5" aria-hidden="true" />
<span className="hidden sm:inline">خروجی</span>
</Button>
<Button
variant="outline"
size="icon"
className="size-8"
aria-label="اعلان‌ها"
>
<Bell className="size-4" aria-hidden="true" />
</Button>
<Button
variant="outline"
size="icon"
className="size-8"
aria-label="راهنما"
>
<HelpCircle className="size-4" aria-hidden="true" />
</Button>
</div>
</header>
);
};
const AccountingStatsCards = () => {
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 deltaPrefix =
stat.footerDelta > 0 ? "+" : stat.footerDelta < 0 ? "-" : "";
const footerLabel = `${deltaPrefix}${currencyFormatter.format(
Math.abs(stat.footerDelta),
)} نسبت به ماه قبل`;
const footerSubtext = `بر اساس ${numberFormatter.format(
stat.footerSubtextCount,
)} سفارش`;
return (
<Card key={stat.title} className="@container/card shadow-none">
<CardHeader>
<CardDescription className="font-medium">
{stat.title}
</CardDescription>
<CardTitle className="text-xl font-bold @[600px]/card:text-xl @[800px]/card:text-2xll">
{currencyFormatter.format(stat.value)}
</CardTitle>
</CardHeader>
<CardFooter className="flex-col items-start gap-1 text-sm">
<div className="flex min-w-0 items-center gap-1.5 truncate font-medium">
<span
className={
isNeutral
? ""
: isPositive
? "text-success"
: "text-destructive"
}
>
{footerLabel}
</span>
<Icon
className={cn(
"size-3.5 shrink-0",
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>
);
};
const RevenueChartTooltip = ({
active,
payload,
label,
}: any) => {
if (!active || !payload?.length) return null;
const revenue = payload.find((p: any) => p.dataKey === "revenue")?.value ?? 0;
const expenses = payload.find((p: any) => p.dataKey === "expenses")?.value ?? 0;
const total = Number(revenue) + Number(expenses);
return (
<div className="rounded-lg border border-border bg-popover p-2 shadow-lg sm:p-3">
<p className="mb-1.5 text-xs font-medium text-foreground sm:mb-2 sm:text-sm">
{label}
</p>
<div className="space-y-1 sm:space-y-1.5">
<div className="flex items-center gap-1.5 sm:gap-2">
<div
className="size-2 rounded-full sm:size-2.5"
style={{ backgroundColor: "var(--color-revenue)" }}
/>
<span className="text-[10px] text-muted-foreground sm:text-sm">
درآمد:
</span>
<span className="text-[10px] font-medium text-foreground sm:text-sm">
{currencyFormatter.format(Number(revenue))}
</span>
</div>
<div className="flex items-center gap-1.5 sm:gap-2">
<div
className="size-2 rounded-full sm:size-2.5"
style={{ backgroundColor: "var(--color-expenses)" }}
/>
<span className="text-[10px] text-muted-foreground sm:text-sm">
هزینه:
</span>
<span className="text-[10px] font-medium text-foreground sm:text-sm">
{currencyFormatter.format(Number(expenses))}
</span>
</div>
<div className="mt-1 border-t border-border pt-1">
<span className="text-[10px] font-medium text-foreground sm:text-xs">
مجموع: {currencyFormatter.format(total)}
</span>
</div>
</div>
</div>
);
};
const RevenueChart = () => {
const totalRevenue = weeklyRevenueData.reduce((sum, d) => sum + d.revenue, 0);
return (
<div className="flex min-w-0 flex-1 flex-col rounded-xl border bg-card">
<div className="flex h-14 items-center justify-between border-b px-4 sm:px-5">
<div className="flex items-center gap-2.5">
<Button
variant="outline"
size="icon"
className="size-7 sm:size-8"
aria-label="درآمد"
>
<BarChart3
className="size-4 text-muted-foreground sm:size-[18px]"
aria-hidden="true"
/>
</Button>
<h2 className="text-sm font-medium text-pretty sm:text-base">
درآمد
</h2>
</div>
<div className="flex items-center gap-3 sm:gap-5">
<div className="flex items-center gap-1.5">
<div
className="size-2 rounded-full sm:size-2.5"
style={{ backgroundColor: palette.primary }}
/>
<span className="text-[10px] text-muted-foreground sm:text-xs">
درآمد
</span>
</div>
<div className="flex items-center gap-1.5">
<div
className="size-2 rounded-full sm:size-2.5"
style={{ backgroundColor: palette.secondary.light }}
/>
<span className="text-[10px] text-muted-foreground sm:text-xs">
هزینه
</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(totalRevenue)}
</p>
<p className="text-[10px] tracking-wider text-muted-foreground uppercase sm:text-xs">
این هفته در برابر هفته قبل
</p>
</div>
<div className="h-[180px] w-full min-w-0 sm:h-[220px]">
<ChartContainer config={revenueChartConfig} className="h-full w-full">
<BarChart data={weeklyRevenueData}>
<CartesianGrid strokeDasharray="0" vertical={false} />
<XAxis
dataKey="day"
axisLine={false}
tickLine={false}
tick={{ fontSize: 10 }}
dy={8}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 10 }}
dx={-5}
tickFormatter={(value) =>
compactCurrencyFormatter.format(value)
}
width={40}
/>
<Tooltip
content={<RevenueChartTooltip />}
cursor={{ fillOpacity: 0.05 }}
/>
<Bar
dataKey="revenue"
stackId="revenue"
fill="var(--color-revenue)"
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="expenses"
stackId="revenue"
fill="var(--color-expenses)"
radius={[3, 3, 0, 0]}
/>
</BarChart>
</ChartContainer>
</div>
</div>
</div>
);
};
const SalesPipelineChart = () => {
const [searchParams, setSearchParams] = React.useState(
() =>
new URLSearchParams(
typeof window !== "undefined" ? window.location.search : "",
),
);
const quarter = searchParams.get("quarter") ?? "q3";
const handleQuarterChange = (value: string) => {
const next = new URLSearchParams(searchParams);
next.set("quarter", value);
setSearchParams(next);
window.history.replaceState(null, "", `?${next.toString()}`);
};
const data = salesPipelineData[quarter] ?? salesPipelineData.q3;
const totalOrders = data.reduce((sum, d) => sum + d.orders, 0);
const totalSales = data.reduce((sum, d) => sum + d.sales, 0);
const pipelineLabelFormatter = (
value: React.ReactNode,
payload: ReadonlyArray<{ payload?: { week?: string } }> | undefined,
) => {
const week = payload?.[0]?.payload?.week;
if (!week) return value;
return `${value} · ${week}`;
};
return (
<div className="flex min-w-0 flex-1 flex-col rounded-xl border bg-card">
<div className="flex h-14 items-center justify-between border-b px-4 sm:px-5">
<div className="flex items-center gap-2.5">
<Button
variant="outline"
size="icon"
className="size-7 sm:size-8"
aria-label="قیف فروش"
>
<BarChart3
className="size-4 text-muted-foreground sm:size-[18px]"
aria-hidden="true"
/>
</Button>
<h2 className="text-sm font-medium text-pretty sm:text-base">
قیف فروش
</h2>
</div>
<Select value={quarter} onValueChange={handleQuarterChange}>
<SelectTrigger
className="h-7 w-[120px] text-xs"
aria-label="انتخاب سه‌ماهه"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="q1">سه‌ماهه ۱</SelectItem>
<SelectItem value="q2">سه‌ماهه ۲</SelectItem>
<SelectItem value="q3">سه‌ماهه ۳</SelectItem>
<SelectItem value="q4">سه‌ماهه ۴</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid flex-1 grid-cols-1 gap-4 p-4 sm:grid-cols-[1fr_auto_1fr] sm:p-5">
<div className="flex min-h-0 flex-col gap-2">
<div>
<p className="text-lg font-semibold tracking-tight">
{numberFormatter.format(totalOrders)}
</p>
<p className="text-[10px] tracking-wider text-muted-foreground uppercase">
مجموع سفارش‌ها
</p>
</div>
<div className="min-h-0 w-full flex-1">
<ChartContainer
config={pipelineOrdersConfig}
className="h-full w-full"
>
<AreaChart data={data} accessibilityLayer>
<defs>
<linearGradient
id="pipelineGradientOrders"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--color-orders)"
stopOpacity={0.3}
/>
<stop
offset="100%"
stopColor="var(--color-orders)"
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<XAxis
dataKey="month"
axisLine={false}
tickLine={false}
tick={{ fontSize: 10 }}
interval={3}
dy={4}
/>
<YAxis hide domain={["dataMin - 50", "dataMax + 50"]} />
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
indicator="line"
labelFormatter={pipelineLabelFormatter}
formatter={(value, name) => {
const numericValue = Number(value);
const formatted = numberFormatter.format(
Number.isFinite(numericValue) ? numericValue : 0,
);
return (
<div className="flex w-full items-center gap-2">
<div
className="h-2.5 w-2.5 shrink-0 rounded-[2px]"
style={{
backgroundColor:
name === "orders"
? "var(--color-orders)"
: "var(--color-sales)",
}}
/>
<span className="text-muted-foreground">
سفارش‌ها
</span>
<span className="ms-auto font-mono font-medium text-foreground tabular-nums">
{formatted}
</span>
</div>
);
}}
/>
}
/>
<Area
type="step"
dataKey="orders"
stroke="var(--color-orders)"
strokeWidth={2}
fill="url(#pipelineGradientOrders)"
/>
</AreaChart>
</ChartContainer>
</div>
</div>
<div className="h-px w-full bg-border sm:h-auto sm:w-px sm:self-stretch" />
<div className="flex min-h-0 flex-col gap-2">
<div>
<p className="text-lg font-semibold tracking-tight">
{compactCurrencyOneDecimalFormatter.format(totalSales)}
</p>
<p className="text-[10px] tracking-wider text-muted-foreground uppercase">
مجموع فروش
</p>
</div>
<div className="min-h-0 w-full flex-1">
<ChartContainer
config={pipelineSalesConfig}
className="h-full w-full"
>
<AreaChart data={data} accessibilityLayer>
<defs>
<linearGradient
id="pipelineGradientSales"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--color-sales)"
stopOpacity={0.3}
/>
<stop
offset="100%"
stopColor="var(--color-sales)"
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<XAxis
dataKey="month"
axisLine={false}
tickLine={false}
tick={{ fontSize: 10 }}
interval={3}
dy={4}
/>
<YAxis hide domain={["dataMin - 50", "dataMax + 50"]} />
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
indicator="line"
labelFormatter={pipelineLabelFormatter}
formatter={(value, name) => {
const numericValue = Number(value);
const formatted = currencyFormatter.format(
Number.isFinite(numericValue) ? numericValue : 0,
);
return (
<div className="flex w-full items-center gap-2">
<div
className="h-2.5 w-2.5 shrink-0 rounded-[2px]"
style={{
backgroundColor:
name === "orders"
? "var(--color-orders)"
: "var(--color-sales)",
}}
/>
<span className="text-muted-foreground">فروش</span>
<span className="ms-auto font-mono font-medium text-foreground tabular-nums">
{formatted}
</span>
</div>
);
}}
/>
}
/>
<Area
type="step"
dataKey="sales"
stroke="var(--color-sales)"
strokeWidth={2}
fill="url(#pipelineGradientSales)"
/>
</AreaChart>
</ChartContainer>
</div>
</div>
</div>
</div>
);
};
const IncomeByCategoryChart = () => {
return (
<div className="flex flex-col gap-4 rounded-xl border bg-card p-4 sm:p-5">
<div className="flex items-center gap-2.5">
<Button
variant="outline"
size="icon"
className="size-7 sm:size-8"
aria-label="فروش بر اساس دسته"
>
<Grid3x3
className="size-4 text-muted-foreground sm:size-[18px]"
aria-hidden="true"
/>
</Button>
<h2 className="text-sm font-medium text-pretty sm:text-base">
فروش بر اساس دسته
</h2>
</div>
<div className="mx-auto aspect-square max-h-[250px] w-full">
<ChartContainer config={radarChartConfig} className="h-full w-full">
<RadarChart data={incomeCategoryData}>
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
<PolarAngleAxis dataKey="category" tick={{ fontSize: 11 }} />
<PolarGrid />
<Radar
dataKey="value"
fill="var(--color-value)"
fillOpacity={0.5}
stroke="var(--color-value)"
strokeWidth={2}
/>
</RadarChart>
</ChartContainer>
</div>
</div>
);
};
const TrafficSourcesHeatmap = () => {
return (
<div className="flex h-full min-w-0 flex-col rounded-xl border bg-card">
<div className="flex h-14 items-center justify-between px-4 sm:px-5">
<div className="flex items-center gap-2.5">
<Button
variant="outline"
size="icon"
className="size-7 sm:size-8"
aria-label="منابع ترافیک"
>
<Grid3x3
className="size-4 text-muted-foreground sm:size-[18px]"
aria-hidden="true"
/>
</Button>
<div className="flex items-center gap-2">
<h2 className="text-sm font-medium text-pretty sm:text-base">
منابع ترافیک
</h2>
<span className="text-xs font-medium text-success">+30%</span>
</div>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col justify-center gap-4 px-4 py-4 sm:px-5">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<div className="size-2.5 rounded-full bg-primary" />
<span className="text-[10px] text-muted-foreground sm:text-xs">
زیاد
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="size-2.5 rounded-full bg-primary/40" />
<span className="text-[10px] text-muted-foreground sm:text-xs">
متوسط
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="size-2.5 rounded-full bg-primary/15" />
<span className="text-[10px] text-muted-foreground sm:text-xs">
کم
</span>
</div>
</div>
<div className="grid grid-cols-[auto_repeat(4,1fr)] gap-0.5 sm:gap-1">
{trafficSourcesData.map((source) => (
<React.Fragment key={source.name}>
<div className="flex items-center pe-2">
<span className="text-[11px] font-medium text-muted-foreground">
{source.total}
</span>
</div>
{source.weeks.map((cell, weekIndex) => {
const intensity = cell.isCurrentWeek
? Math.round(cell.value * 30 + 65)
: Math.round(cell.value * 35 + 15);
const tooltipTitle = source.name || "کل";
const weekLabel =
weekLabels[weekIndex] ??
`هفته ${(weekIndex + 1).toLocaleString("fa-IR")}`;
const strength = Math.round(cell.value * 100);
return (
<ShadTooltipProvider key={weekIndex}>
<ShadTooltip>
<ShadTooltipTrigger asChild>
<button
type="button"
className="h-5 w-full rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none sm:h-6"
style={{
backgroundColor: `color-mix(in oklch, var(--primary) ${intensity}%, ${mixBase})`,
}}
aria-label={`${tooltipTitle} ${weekLabel}: ${strength}%`}
/>
</ShadTooltipTrigger>
<ShadTooltipContent side="top" sideOffset={8}>
<div className="grid gap-1">
<div className="font-medium">{tooltipTitle}</div>
<div className="text-muted-foreground">
{weekLabel}
{cell.isCurrentWeek ? " · جاری" : ""}
</div>
<div className="tabular-nums">
{strength.toLocaleString("fa-IR")}٪
</div>
</div>
</ShadTooltipContent>
</ShadTooltip>
</ShadTooltipProvider>
);
})}
</React.Fragment>
))}
<div className="pt-2" />
{weekLabels.map((label) => (
<div
key={label}
className="pt-2 text-center text-[10px] text-muted-foreground"
>
{label}
</div>
))}
</div>
</div>
</div>
);
};
const OrderStatusChart = () => {
const total = orderStatIRRata.reduce((sum, d) => sum + d.value, 0);
const sliceColors = [palette.primary, palette.tertiary.light];
const [activeIndex, setActiveIndex] = React.useState<number | null>(null);
return (
<div className="flex flex-col gap-4 rounded-xl border bg-card p-4 sm:p-5">
<div className="flex items-center gap-2.5">
<Button
variant="outline"
size="icon"
className="size-7 sm:size-8"
aria-label="وضعیت سفارش"
>
<Users
className="size-4 text-muted-foreground sm:size-[18px]"
aria-hidden="true"
/>
</Button>
<h2 className="text-sm font-medium text-pretty sm:text-base">
وضعیت سفارش
</h2>
</div>
<div className="relative mx-auto w-full max-w-[220px] sm:max-w-[240px]">
<ChartContainer
config={orderStatusChartConfig}
className="aspect-square w-full"
>
<PieChart>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Pie
data={orderStatIRRata}
dataKey="value"
nameKey="key"
innerRadius="60%"
outerRadius="85%"
paddingAngle={2}
strokeWidth={0}
{...({ activeIndex: activeIndex ?? undefined } as any)}
activeShape={({
outerRadius = 0,
...props
}: PieSectorDataItem) => (
<Sector {...props} outerRadius={outerRadius + 8} />
)}
onMouseEnter={(_, index) => setActiveIndex(index)}
onMouseLeave={() => setActiveIndex(null)}
onClick={(_, index) =>
setActiveIndex((current) => (current === index ? null : index))
}
>
{orderStatIRRata.map((entry, index) => (
<Cell
key={entry.key}
fill={sliceColors[index]}
fillOpacity={
activeIndex !== null && activeIndex !== index ? 0.4 : 1
}
/>
))}
</Pie>
</PieChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-[10px] tracking-wider text-muted-foreground uppercase">
سفارش‌ها
</span>
<span className="text-lg font-semibold">
{numberFormatter.format(total)}
</span>
</div>
</div>
<div className="flex items-center justify-center gap-6">
{orderStatIRRata.map((item, index) => (
<button
key={item.key}
type="button"
className={cn(
"flex items-center gap-2 rounded-md px-1 py-0.5 transition-opacity duration-200 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none",
activeIndex !== null && activeIndex !== index && "opacity-50",
)}
onPointerEnter={() => setActiveIndex(index)}
onPointerLeave={() => setActiveIndex(null)}
onFocus={() => setActiveIndex(index)}
onBlur={() => setActiveIndex(null)}
onClick={() =>
setActiveIndex((current) => (current === index ? null : index))
}
aria-label={`${item.label}: ${numberFormatter.format(item.value)} سفارش`}
>
<span
className="size-2.5 rounded-full"
style={{ backgroundColor: sliceColors[index] }}
/>
<span className="text-xs text-muted-foreground">{item.label}</span>
</button>
))}
</div>
</div>
);
};
const RecentInvoicesTable = () => {
const [searchQuery, setSearchQuery] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState<string>("all");
const [currentPage, setCurrentPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(5);
const [sortKey, setSortKey] = React.useState<InvoiceSortKey | "">("");
const [sortDir, setSortDir] = React.useState<"asc" | "desc">("asc");
const [isHydrated, setIsHydrated] = React.useState(false);
const skipNextReset = React.useRef(true);
const hasActiveFilters = statusFilter !== "all";
const clearFilters = () => {
setStatusFilter("all");
};
// Hydrate from URL params on mount
React.useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
const statusParam = params.get("invoiceStatus");
const pageParam = Number.parseInt(params.get("page") ?? "", 10);
const pageSizeParam = Number.parseInt(params.get("pageSize") ?? "", 10);
const sortKeyParam = params.get("sortKey") as InvoiceSortKey | null;
const sortDirParam = params.get("sortDir");
setSearchQuery(params.get("q") ?? "");
setStatusFilter(
statusParam && invoiceStatuses.includes(statusParam as InvoiceStatus)
? statusParam
: "all",
);
setCurrentPage(Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1);
setPageSize(
INVOICE_PAGE_SIZE_OPTIONS.includes(pageSizeParam) ? pageSizeParam : 5,
);
if (
sortKeyParam &&
["customer", "invoiceId", "date", "amount"].includes(sortKeyParam)
) {
setSortKey(sortKeyParam);
setSortDir(sortDirParam === "desc" ? "desc" : "asc");
}
setIsHydrated(true);
}, []);
// Reset page to 1 when filters change
React.useEffect(() => {
if (!isHydrated) return;
if (skipNextReset.current) {
skipNextReset.current = false;
return;
}
setCurrentPage(1);
}, [searchQuery, statusFilter, pageSize, isHydrated]);
// Sync state to URL
React.useEffect(() => {
if (!isHydrated) return;
if (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("invoiceStatus", statusFilter);
} else {
params.delete("invoiceStatus");
}
if (currentPage > 1) {
params.set("page", String(currentPage));
} else {
params.delete("page");
}
if (pageSize !== INVOICE_PAGE_SIZE_OPTIONS[0]) {
params.set("pageSize", String(pageSize));
} else {
params.delete("pageSize");
}
if (sortKey) {
params.set("sortKey", sortKey);
params.set("sortDir", sortDir);
} else {
params.delete("sortKey");
params.delete("sortDir");
}
const nextQuery = params.toString();
const nextUrl = nextQuery
? `${window.location.pathname}?${nextQuery}`
: window.location.pathname;
window.history.replaceState(null, "", nextUrl);
}, [
searchQuery,
statusFilter,
currentPage,
pageSize,
sortKey,
sortDir,
isHydrated,
]);
const handleSort = (key: InvoiceSortKey) => {
if (sortKey === key) {
setSortDir((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("asc");
}
};
const filteredInvoices = React.useMemo(() => {
return invoices.filter((invoice) => {
const matchesSearch =
invoice.customer.toLowerCase().includes(searchQuery.toLowerCase()) ||
invoice.invoiceId.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus =
statusFilter === "all" || invoice.status === statusFilter;
return matchesSearch && matchesStatus;
});
}, [searchQuery, statusFilter]);
const sortedInvoices = React.useMemo(() => {
if (!sortKey) return filteredInvoices;
return [...filteredInvoices].sort((a, b) => {
let cmp = 0;
switch (sortKey) {
case "customer":
cmp = a.customer.localeCompare(b.customer);
break;
case "invoiceId":
cmp = a.invoiceId.localeCompare(b.invoiceId);
break;
case "date":
cmp = a.date.localeCompare(b.date);
break;
case "amount":
cmp = a.amount - b.amount;
break;
}
return sortDir === "desc" ? -cmp : cmp;
});
}, [filteredInvoices, sortKey, sortDir]);
const totalPages = Math.max(1, Math.ceil(sortedInvoices.length / pageSize));
const paginatedInvoices = React.useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
return sortedInvoices.slice(startIndex, startIndex + pageSize);
}, [sortedInvoices, currentPage, pageSize]);
const goToPage = (page: number) => {
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
};
const sortableHeader = (
label: string,
key: InvoiceSortKey,
className?: string,
) => (
<TableHead
className={cn(
"text-xs font-medium text-muted-foreground sm:text-sm",
className,
)}
aria-sort={
sortKey === key
? sortDir === "asc"
? "ascending"
: "descending"
: undefined
}
>
<button
type="button"
className="inline-flex cursor-pointer items-center gap-1"
onClick={() => handleSort(key)}
>
{label}
<ArrowUpDown
className="size-3 text-muted-foreground/60"
aria-hidden="true"
/>
</button>
</TableHead>
);
return (
<div>
<div className="flex flex-col gap-3 py-3 sm:flex-row sm:items-center sm:gap-4 sm:py-3.5">
<div className="flex flex-1 flex-col gap-0.5">
<div className="flex items-center gap-2.5">
<h2 className="text-sm font-medium text-pretty sm:text-base">
فاکتورهای اخیر
</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">
{sortedInvoices.length.toLocaleString("fa-IR")}
</span>
</div>
<p className="hidden text-xs text-muted-foreground sm:block">
فاکتورهای اخیر در جدول زیر نمایش داده می‌شوند.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<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="جستجوی فاکتور… (مثلاً فاکتور-00012)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
name="invoice-search"
inputMode="search"
autoComplete="off"
aria-label="جستجوی فاکتورها"
className="h-8 w-full pe-12 ps-9 text-sm sm:h-9 sm:w-[180px] lg:w-[220px]"
/>
<kbd className="pointer-events-none absolute top-1/2 end-2.5 hidden -translate-y-1/2 rounded border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground select-none sm:inline-block">
⌘1
</kbd>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-8 gap-1.5 sm:h-9 sm:gap-2",
hasActiveFilters && "border-primary",
)}
aria-label="فیلتر فاکتورها"
>
<Filter className="size-3.5 sm:size-4" aria-hidden="true" />
<span className="hidden sm:inline">فیلتر</span>
{hasActiveFilters && (
<span className="size-1.5 rounded-full bg-primary sm:size-2" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[180px]">
<DropdownMenuLabel>فیلتر بر اساس وضعیت</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={statusFilter === "all"}
onCheckedChange={() => setStatusFilter("all")}
>
همه وضعیت‌ها
</DropdownMenuCheckboxItem>
{invoiceStatuses.map((status) => (
<DropdownMenuCheckboxItem
key={status}
checked={statusFilter === status}
onCheckedChange={() => setStatusFilter(status)}
>
{invoiceStatusLabels[status]}
</DropdownMenuCheckboxItem>
))}
{hasActiveFilters && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={clearFilters}
className="text-destructive"
>
<X className="me-2 size-4" aria-hidden="true" />
پاک کردن فیلترها
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="sm"
className="h-8 text-xs sm:h-9 sm:text-sm"
>
مشاهده همه
</Button>
</div>
</div>
{hasActiveFilters && (
<div className="flex flex-wrap items-center gap-2 pb-3">
<span className="text-[10px] text-muted-foreground sm:text-xs">
فیلترها:
</span>
{statusFilter !== "all" && (
<button
type="button"
className="inline-flex cursor-pointer items-center gap-1 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"
onClick={() => setStatusFilter("all")}
aria-label={`حذف فیلتر ${invoiceStatusLabels[statusFilter as InvoiceStatus]}`}
>
{invoiceStatusLabels[statusFilter as InvoiceStatus]}
<X className="size-2.5 sm:size-3" aria-hidden="true" />
</button>
)}
</div>
)}
<div className="overflow-x-auto pb-3 sm:pb-4">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
{sortableHeader("مشتری", "customer", "min-w-[160px]")}
{sortableHeader("شناسه فاکتور", "invoiceId", "min-w-[120px]")}
{sortableHeader(
"تاریخ",
"date",
"hidden min-w-[100px] sm:table-cell",
)}
{sortableHeader("مبلغ", "amount", "min-w-[90px]")}
<TableHead className="min-w-[80px] text-xs font-medium text-muted-foreground sm:text-sm">
اولویت
</TableHead>
<TableHead className="min-w-[80px] text-xs font-medium text-muted-foreground sm:text-sm">
وضعیت
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedInvoices.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="h-24 text-center text-sm text-muted-foreground"
>
فاکتوری با این فیلترها یافت نشد.
</TableCell>
</TableRow>
) : (
paginatedInvoices.map((invoice) => (
<TableRow key={invoice.id}>
<TableCell>
<div className="flex items-center gap-2">
<Avatar className="size-8">
<AvatarFallback className="text-xs">
{invoice.customerInitials}
</AvatarFallback>
</Avatar>
<span className="text-xs font-medium sm:text-sm">
{invoice.customer}
</span>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground tabular-nums sm:text-sm">
{invoice.invoiceId}
</TableCell>
<TableCell className="hidden text-xs text-muted-foreground sm:table-cell sm:text-sm">
{invoiceDateFormatter.format(new Date(invoice.date))}
</TableCell>
<TableCell className="text-xs font-medium text-foreground tabular-nums sm:text-sm">
{currencyFormatter.format(invoice.amount)}
</TableCell>
<TableCell>
<span
className={cn(
badgeClassName,
invoicePriorityStyles[invoice.priority],
)}
>
{invoicePriorityLabels[invoice.priority]}
</span>
</TableCell>
<TableCell>
<span
className={cn(
badgeClassName,
invoiceStatusStyles[invoice.status],
)}
>
{invoiceStatusLabels[invoice.status]}
</span>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="flex flex-col items-center justify-between gap-3 border-t py-3 sm:flex-row">
<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>
{INVOICE_PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={size.toString()}>
{size.toLocaleString("fa-IR")}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground">
{sortedInvoices.length === 0
? "۰"
: `${((currentPage - 1) * pageSize + 1).toLocaleString("fa-IR")}-${Math.min(
currentPage * pageSize,
sortedInvoices.length,
).toLocaleString("fa-IR")}`}{" "}
از {sortedInvoices.length.toLocaleString("fa-IR")}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => goToPage(1)}
disabled={currentPage === 1}
aria-label="رفتن به صفحه اول"
>
<ChevronsLeft className="size-4 rtl:rotate-180" aria-hidden="true" />
</Button>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
aria-label="رفتن به صفحه قبل"
>
<ChevronLeft className="size-4 rtl:rotate-180" aria-hidden="true" />
</Button>
<div className="flex items-center gap-1 px-2">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let pageNum: number;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (currentPage <= 3) {
pageNum = i + 1;
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = currentPage - 2 + i;
}
return (
<Button
key={pageNum}
variant={currentPage === pageNum ? "default" : "ghost"}
size="icon"
className="size-8"
onClick={() => goToPage(pageNum)}
aria-label={`رفتن به صفحه ${pageNum.toLocaleString("fa-IR")}`}
aria-current={currentPage === pageNum ? "page" : undefined}
>
{pageNum.toLocaleString("fa-IR")}
</Button>
);
})}
</div>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
aria-label="رفتن به صفحه بعد"
>
<ChevronRight className="size-4 rtl:rotate-180" aria-hidden="true" />
</Button>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => goToPage(totalPages)}
disabled={currentPage === totalPages}
aria-label="رفتن به صفحه آخر"
>
<ChevronsRight className="size-4 rtl:rotate-180" aria-hidden="true" />
</Button>
</div>
</div>
</div>
);
};
const DashboardContent = () => {
return (
<div
id="dashboard-main"
tabIndex={-1}
className="w-full flex-1 space-y-4 overflow-auto bg-background p-3 sm:space-y-6 sm:p-4 md:p-6"
>
<AccountingStatsCards />
<div className="flex flex-col gap-4 sm:gap-6 xl:flex-row">
<RevenueChart />
<SalesPipelineChart />
</div>
<div className="grid gap-4 sm:gap-6 lg:grid-cols-3">
<IncomeByCategoryChart />
<TrafficSourcesHeatmap />
<OrderStatusChart />
</div>
<RecentInvoicesTable />
</div>
);
};
const Dashboard8 = ({ className }: { className?: string }) => {
return (
<ShadTooltipProvider>
<SidebarProvider
dir="rtl"
lang="fa"
className={cn("min-h-svh w-full overflow-hidden bg-sidebar", 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>
<AppSidebar />
<SidebarInset className="min-h-svh min-w-0 w-full overflow-hidden md:peer-data-[variant=inset]:m-0 md:peer-data-[variant=inset]:rounded-none md:peer-data-[variant=inset]:shadow-none md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-0 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:me-0">
<DashboardHeader />
<DashboardContent />
</SidebarInset>
</SidebarProvider>
</ShadTooltipProvider>
);
};
export { Dashboard8 };