Files

1972 lines
65 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 {
Activity,
ArrowDownRight,
ArrowUpRight,
BarChart3,
Bell,
Building2,
ChevronRight,
ChevronsUpDown,
CircleDollarSign,
CreditCard,
Download,
FileText,
Layers,
LayoutDashboard,
LineChart as LineChartIcon,
LogOut,
MoreHorizontal,
Receipt,
Search,
Settings,
TrendingDown,
User,
UserPlus,
Users,
Zap,
} from "lucide-react";
import * as React from "react";
import type { TooltipContentProps } from "recharts";
import {
Area,
Bar,
BarChart,
CartesianGrid,
Cell,
ComposedChart,
Line,
LineChart,
Pie,
PieChart,
ReferenceLine,
Sector,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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 { TooltipProvider } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
// ============================================================================
// Types
// ============================================================================
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;
href: string;
};
navGroups: NavGroup[];
footerGroup: NavGroup;
user?: UserData;
};
type StatItem = {
title: string;
previousValue: number;
value: number;
changePercent: number;
isPositive: boolean;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
format: "currency" | "number";
};
// ============================================================================
// Formatting Helpers
// ============================================================================
const currencyFormatter = new Intl.NumberFormat("fa-IR", {
style: "currency",
currency: "IRR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
const numberFormatter = new Intl.NumberFormat("fa-IR", {
maximumFractionDigits: 0,
});
const percentFormatter = new Intl.NumberFormat("fa-IR", {
style: "percent",
maximumFractionDigits: 0,
});
const compactCurrencyFormatter = new Intl.NumberFormat("fa-IR", {
style: "currency",
currency: "IRR",
notation: "compact",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
/** `font-mono tabular-nums` for large hero KPI values only; smaller figures stay sans (see `tabular-nums` where alignment helps). */
const numericMonoClass = "font-mono tabular-nums";
const chartCurrencyTickProps = {
fontSize: 10,
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
} as const;
const monthLabel = (monthIndex: number) =>
new Intl.DateTimeFormat("fa-IR", { month: "short" }).format(
new Date(2025, monthIndex, 1),
);
// ============================================================================
// Chart Palette (color-mix from primary)
// ============================================================================
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})`,
},
};
// ============================================================================
// Mock Data
// ============================================================================
const sidebarData: SidebarData = {
logo: {
src: "/block-media/shadcnblocks/block/logos/NovaSales-logo.svg",
alt: "نوافروش",
title: "NovaSales",
description: "پلتفرم فروش ابری",
href: "https://www.NovaSales",
},
navGroups: [
{
title: "نمای کلی",
defaultOpen: true,
items: [
{
label: "داشبورد",
icon: LayoutDashboard,
href: "#",
isActive: true,
},
{ label: "تحلیل فروش", icon: BarChart3, href: "#" },
{ label: "فعالیت تیم", icon: Activity, href: "#" },
],
},
{
title: "اعضای تیم",
defaultOpen: true,
items: [
{
label: "فهرست معاملات",
icon: Users,
href: "#",
children: [
{ label: "همه اعضای تیم", icon: User, href: "#" },
{ label: "فضاهای کاری", icon: Building2, href: "#" },
],
},
{ label: "دعوت‌ها", icon: UserPlus, href: "#" },
],
},
{
title: "صورتحساب",
defaultOpen: false,
items: [
{ label: "اشتراک‌ها", icon: CreditCard, href: "#" },
{ label: "مصرف", icon: Zap, href: "#" },
{ label: "فاکتورها", icon: Receipt, href: "#" },
],
},
{
title: "گزارش‌ها",
defaultOpen: false,
items: [
{ label: "گزارش‌های ذخیره‌شده", icon: FileText, href: "#" },
{ label: "خروجی‌ها", icon: Download, href: "#" },
],
},
],
footerGroup: {
title: "تنظیمات",
items: [{ label: "تنظیمات", icon: Settings, href: "#" }],
},
user: {
name: "مریم الیس",
email: "morgan.ellis@northwind.app",
avatar:
"/block-media/shadcnblocks/block/avatar-1.webp",
},
};
/** Highlights for the welcome line (growth + revenue health). */
const dashboardHighlights = {
signupsThisWeek: 86,
trialsEndingSoon: 14,
} as const;
const headerNotificationItems = [
{
id: "1",
title: "پرداخت دریافت شد",
body: "شرکت آکمه فاکتور ۱۰۴۲ را (۲٬۴۰۰ ریال) پرداخت کرد.",
time: "۲ دقیقه پیش",
},
{
id: "2",
title: "هشدار سقف صندلی",
body: "نورث‌ویند به ۹۰٪ صندلی‌های مجاز رسیده است.",
time: "۱ ساعت پیش",
},
{
id: "3",
title: "پایان دوره آزمایشی",
body: "۳ فضای کاری ظرف ۴۸ ساعت دوره آزمایشی‌شان تمام می‌شود.",
time: "۳ ساعت پیش",
},
{
id: "4",
title: "گزارش هفتگی آماده است",
body: "خلاصه MRR هفته گذشته در دسترس است.",
time: "دیروز",
},
] as const;
const unreadNotificationCount = headerNotificationItems.length;
const statsData: StatItem[] = [
{
title: "درآمد ماهانه (درآمد ماهانه تکرارشونده)",
previousValue: 148_230.4,
value: 189_540.75,
changePercent: 27.86,
isPositive: true,
icon: CircleDollarSign,
format: "currency",
},
{
title: "اعضای تیم فعال ماهانه",
previousValue: 45_890,
value: 52_340,
changePercent: 14.06,
isPositive: true,
icon: Users,
format: "number",
},
{
title: "مشتریان پرداخت‌کننده",
previousValue: 1_842,
value: 2_047,
changePercent: 11.13,
isPositive: true,
icon: Building2,
format: "number",
},
{
title: "درآمد ماهانه تکرارشونده از دست‌رفته",
previousValue: 11_234.56,
value: 9_847.23,
changePercent: 12.35,
isPositive: true,
icon: TrendingDown,
format: "currency",
},
];
const channelRevenueData = [
{
month: monthLabel(0),
starter: 18_200,
pro: 28_640,
enterprise: 31_850,
addOns: 10_520,
},
{
month: monthLabel(1),
starter: 17_410,
pro: 29_450,
enterprise: 33_720,
addOns: 9_380,
},
{
month: monthLabel(2),
starter: 19_840,
pro: 30_920,
enterprise: 34_240,
addOns: 8_900,
},
{
month: monthLabel(3),
starter: 18_970,
pro: 31_880,
enterprise: 35_910,
addOns: 10_140,
},
{
month: monthLabel(4),
starter: 20_620,
pro: 32_510,
enterprise: 36_680,
addOns: 11_290,
},
{
month: monthLabel(5),
starter: 21_190,
pro: 33_340,
enterprise: 38_620,
addOns: 11_740,
},
];
const fullYearData = [
{ month: monthLabel(0), thisYear: 41_280, prevYear: 38_450 },
{ month: monthLabel(1), thisYear: 37_920, prevYear: 44_680 },
{ month: monthLabel(2), thisYear: 52_140, prevYear: 41_230 },
{ month: monthLabel(3), thisYear: 45_890, prevYear: 47_520 },
{ month: monthLabel(4), thisYear: 57_640, prevYear: 43_910 },
{ month: monthLabel(5), thisYear: 41_580, prevYear: 51_240 },
{ month: monthLabel(6), thisYear: 54_720, prevYear: 46_880 },
{ month: monthLabel(7), thisYear: 48_310, prevYear: 52_690 },
{ month: monthLabel(8), thisYear: 61_050, prevYear: 49_420 },
{ month: monthLabel(9), thisYear: 53_880, prevYear: 57_910 },
{ month: monthLabel(10), thisYear: 66_420, prevYear: 52_340 },
{ month: monthLabel(11), thisYear: 70_890, prevYear: 60_760 },
];
/**
* Weekly buckets for “This month” total revenue (same unit scale as `fullYearData` months).
* Sums match March vs February `thisYear` in `fullYearData` so MTD stays below full-year total.
*/
const revenueMonthTimelineData = [
{ label: "هفته ۱", current: 12_600, previous: 9_200 },
{ label: "هفته ۲", current: 13_050, previous: 9_600 },
{ label: "هفته ۳", current: 13_200, previous: 9_450 },
{ label: "هفته ۴", current: 13_290, previous: 9_670 },
];
const chartDates = Array.from({ length: 28 }, (_, index) =>
new Intl.DateTimeFormat("fa-IR", { day: "numeric", month: "short" }).format(
new Date(2025, 1, index + 1),
),
);
const aovValues = [
687, 918, 763, 1036, 824, 1174, 719, 983, 1116, 742, 957, 1083, 704, 938,
1194, 862, 778, 1157, 827, 1094, 976, 764, 1253, 883, 1138, 963, 1216, 1077,
];
const aovData = chartDates.map((date, index) => ({
date,
value: aovValues[index],
}));
const chartDates90 = Array.from(
{ length: 90 },
(_, i) => `روز ${(i + 1).toLocaleString("fa-IR")}`,
);
const aovValues90 = Array.from({ length: 90 }, (_, i) => {
const base = aovValues[i % aovValues.length];
return Math.round(base + ((i * 19) % 47) - 23);
});
const aovData90 = chartDates90.map((date, index) => ({
date,
value: aovValues90[index],
}));
const aovAverage90 =
aovValues90.reduce((total, value) => total + value, 0) / aovValues90.length;
const aovAverage =
aovValues.reduce((total, value) => total + value, 0) / aovValues.length;
const averageSalesThisMonth = [
6380, 7240, 6820, 7360, 7930, 8170, 7580, 7030, 7820, 8470, 8320, 7880, 7630,
7180, 7970, 8760, 8360, 8230, 8580, 8970, 8720, 9070, 9470, 9820, 9960, 10180,
9770, 10340,
];
const averageSalesLastMonth = [
5620, 5970, 5920, 6180, 6430, 6970, 6620, 6280, 6530, 7170, 7360, 7030, 6770,
6630, 6920, 7380, 7080, 7020, 7280, 7580, 7420, 7760, 8170, 8470, 8680, 8870,
8580, 8940,
];
const averageSalesData = chartDates.map((date, index) => ({
date,
thisMonth: averageSalesThisMonth[index],
lastMonth: averageSalesLastMonth[index],
}));
type PlanMixRow = {
name: string;
value: number;
color: string;
};
const channelMixBase: PlanMixRow[] = [
{ name: "رایگان", value: 44.2, color: palette.primary },
{
name: "حرفه‌ای",
value: 28.6,
color: `color-mix(in oklch, var(--primary) 80%, ${mixBase})`,
},
{
name: "سازمانی",
value: 19.8,
color: `color-mix(in oklch, var(--primary) 60%, ${mixBase})`,
},
{
name: "تیمی",
value: 7.4,
color: `color-mix(in oklch, var(--primary) 42%, ${mixBase})`,
},
];
const channelMixByWindow: Record<"7d" | "28d" | "90d", PlanMixRow[]> = {
"7d": [
{ ...channelMixBase[0], value: 41.2 },
{ ...channelMixBase[1], value: 29.4 },
{ ...channelMixBase[2], value: 20.1 },
{ ...channelMixBase[3], value: 9.3 },
],
"28d": channelMixBase,
"90d": [
{ ...channelMixBase[0], value: 46.8 },
{ ...channelMixBase[1], value: 26.2 },
{ ...channelMixBase[2], value: 18.9 },
{ ...channelMixBase[3], value: 8.1 },
],
};
// ============================================================================
// Chart Configs
// ============================================================================
const revenueFlowChartConfig = {
thisYear: { label: "امسال", color: palette.primary },
prevYear: { label: "سال قبل", theme: palette.secondary },
} satisfies ChartConfig;
const revenueMonthChartConfig = {
current: { label: "این ماه", color: palette.primary },
previous: { label: "ماه قبل", theme: palette.secondary },
} satisfies ChartConfig;
const aovChartConfig = {
value: { label: "ARPU", color: palette.primary },
reference: { label: "میانگین", theme: palette.secondary },
} satisfies ChartConfig;
const averageSalesChartConfig = {
thisMonth: { label: "این ماه", color: palette.primary },
lastMonth: { label: "ماه قبل", theme: palette.secondary },
} satisfies ChartConfig;
const channelChartConfig = {
starter: { label: "پایه", color: palette.primary },
pro: { label: "حرفه‌ای", theme: palette.secondary },
enterprise: { label: "سازمانی", theme: palette.tertiary },
addOns: { label: "افزونه‌ها", theme: palette.quaternary },
} satisfies ChartConfig;
const channelMixChartConfig = {
free: { label: "رایگان", color: palette.primary },
pro: { label: "حرفه‌ای", theme: palette.secondary },
enterprise: { label: "سازمانی", theme: palette.tertiary },
team: { label: "تیمی", theme: palette.quaternary },
} satisfies ChartConfig;
// ============================================================================
// Sidebar Components
// ============================================================================
const SidebarLogo = ({ logo }: { logo: SidebarData["logo"] }) => {
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" tooltip={logo.title} asChild>
<a
href={logo.href}
target="_blank"
rel="noopener noreferrer"
className="min-w-0"
>
<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 min-w-0 flex-col gap-0.5 leading-none">
<span className="truncate font-medium">{logo.title}</span>
<span className="truncate text-xs text-muted-foreground">
{logo.description}
</span>
</div>
</a>
</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 }) => {
const initials = user.name
.split(" ")
.map((n) => n[0])
.join("");
const userAvatar = (
<Avatar className="size-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
</Avatar>
);
const userInfo = (
<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>
);
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
{userAvatar}
{userInfo}
<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">
{userAvatar}
{userInfo}
</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>
);
};
// ============================================================================
// Dashboard Header
// ============================================================================
const DashboardHeader = () => {
return (
<header className="flex w-full items-center gap-3 border-b bg-background px-4 py-4 sm:px-6">
<LayoutDashboard className="size-5" aria-hidden="true" />
<h1 className="text-base font-medium">داشبورد</h1>
<div className="ms-auto flex items-center gap-2">
<div className="relative w-full max-w-[220px] sm:max-w-[260px]">
<Search
className="absolute top-1/2 start-3 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
type="search"
name="header-search"
inputMode="search"
autoComplete="off"
aria-label="جستجو در داشبورد"
placeholder="جستجوی اعضای تیم، فضاهای کاری…"
className="h-9 w-full pe-14 ps-9 text-sm"
/>
<kbd className="pointer-events-none absolute top-1/2 end-2 hidden -translate-y-1/2 rounded-md border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline-flex">
{"\u2318"}
{"\u00a0"}K
</kbd>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="relative size-9"
aria-label={`اعلان‌ها، ${unreadNotificationCount.toLocaleString("fa-IR")} خوانده‌نشده`}
>
<Bell className="size-4" aria-hidden="true" />
<span className="absolute -top-1 -end-1 flex size-[18px] items-center justify-center rounded-full bg-red-600 text-[10px] leading-none font-semibold text-white ring-2 ring-background">
{unreadNotificationCount.toLocaleString("fa-IR")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 sm:w-96">
<DropdownMenuLabel className="text-base font-medium">
اعلان‌ها
</DropdownMenuLabel>
<DropdownMenuSeparator />
{headerNotificationItems.map((item) => (
<DropdownMenuItem
key={item.id}
className="cursor-default flex-col items-start gap-1 py-3"
onSelect={(event: Event) => event.preventDefault()}
>
<span className="font-medium text-foreground">
{item.title}
</span>
<span className="text-xs leading-snug font-normal text-muted-foreground">
{item.body}
</span>
<span className="text-[10px] text-muted-foreground/80">
{item.time}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem className="justify-center text-center font-medium">
علامت‌گذاری همه به‌عنوان خوانده‌شده
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
};
// ============================================================================
// Welcome Section
// ============================================================================
const WelcomeSection = () => {
const [inviteOpen, setInviteOpen] = React.useState(false);
const [inviteEmail, setInviteEmail] = React.useState("");
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
<p className="hidden text-sm text-muted-foreground lg:block lg:text-base">
<span className="font-medium text-foreground">
{dashboardHighlights.signupsThisWeek.toLocaleString("fa-IR")} ثبت‌نام
</span>{" "}
این هفته ·{" "}
<span className="font-medium text-foreground">
{dashboardHighlights.trialsEndingSoon.toLocaleString("fa-IR")} دوره
آزمایشی
</span>{" "}
در ۷ روز آینده منقضی می‌شود
</p>
<div className="flex flex-wrap items-center gap-2 sm:ms-auto sm:gap-3 lg:ms-0">
<Button
variant="outline"
size="sm"
className="h-8 gap-2 sm:h-9"
aria-label="خروجی"
>
<Download className="size-4" aria-hidden="true" />
<span className="hidden sm:inline">خروجی</span>
</Button>
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
<DialogTrigger asChild>
<Button
size="sm"
className="h-8 gap-2 sm:h-9"
aria-label="دعوت کاربر"
>
<UserPlus className="size-4" aria-hidden="true" />
<span className="hidden sm:inline">دعوت</span>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>دعوت افراد</DialogTitle>
<DialogDescription>
دعوت‌نامه را با ایمیل ارسال کنید. آن‌ها لینک پیوستن به فضای
کاری شما را دریافت می‌کنند.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="dashboard-invite-email">ایمیل</Label>
<Input
id="dashboard-invite-email"
type="email"
name="invite-email"
autoComplete="email"
placeholder="colleague@company.com"
value={inviteEmail}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setInviteEmail(e.target.value)
}
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
انصراف
</Button>
</DialogClose>
<Button
type="button"
onClick={() => {
setInviteOpen(false);
setInviteEmail("");
}}
>
ارسال دعوت
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
};
// ============================================================================
// Stats Cards
// ============================================================================
const StatsCards = () => {
return (
<div className="grid grid-cols-2 gap-3 rounded-xl border bg-card p-4 sm:gap-4 sm:p-5 lg:grid-cols-4 lg:gap-6 lg:p-6">
{statsData.map((stat, index) => {
const formatter =
stat.format === "currency" ? currencyFormatter : numberFormatter;
return (
<div key={stat.title} className="flex items-start">
<div className="flex-1 space-y-1 sm:space-y-2 lg:space-y-3">
<div className="flex items-center gap-1.5 text-muted-foreground sm:gap-2">
<stat.icon className="size-3.5 sm:size-4" aria-hidden="true" />
<span className="truncate text-[10px] font-medium sm:text-xs lg:text-sm">
{stat.title}
</span>
</div>
<p className="hidden text-[10px] text-muted-foreground/70 tabular-nums sm:block sm:text-xs">
{formatter.format(stat.previousValue)} ماه قبل
</p>
<p
className={cn(
"text-xl leading-tight font-semibold tracking-tight sm:text-xl lg:text-[28px]",
numericMonoClass,
)}
>
{formatter.format(stat.value)}
</p>
<div className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-[10px] sm:text-xs">
{stat.isPositive ? (
<ArrowUpRight
className="size-3 shrink-0 text-emerald-600 sm:size-3.5"
aria-hidden="true"
/>
) : (
<ArrowDownRight
className="size-3 shrink-0 text-red-600 sm:size-3.5"
aria-hidden="true"
/>
)}
<span
className={cn(
"whitespace-nowrap tabular-nums",
stat.isPositive ? "text-emerald-600" : "text-red-600",
)}
>
{stat.isPositive ? "+" : "-"}
{Math.round(stat.changePercent).toLocaleString("fa-IR")}٪
</span>
<span className="whitespace-nowrap text-muted-foreground">
نسبت به ماه قبل
</span>
</div>
</div>
{index < statsData.length - 1 && (
<div className="mx-4 hidden h-full w-px bg-border lg:block xl:mx-6" />
)}
</div>
);
})}
</div>
);
};
// ============================================================================
// Channel Revenue Chart
// ============================================================================
function ChannelTooltip({
active,
payload,
label,
}: TooltipContentProps) {
if (!active || !payload?.length) return null;
const total = payload.reduce(
(sum, entry) => 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) => {
const dataKey = String(
entry.dataKey ?? "",
) as keyof typeof channelChartConfig;
const label =
channelChartConfig[dataKey]?.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="text-[10px] text-muted-foreground sm:text-xs">
{label}:
</span>
<span className="text-[10px] font-medium text-foreground tabular-nums sm:text-xs">
{compactCurrencyFormatter.format(Number(entry.value))}
</span>
</div>
);
})}
<div className="border-t border-border pt-1">
<span className="text-[10px] font-medium text-foreground tabular-nums sm:text-xs">
جمع: {compactCurrencyFormatter.format(total)}
</span>
</div>
</div>
</div>
);
}
const ChannelRevenueChart = () => {
const [mrrRange, setMrrRange] = React.useState<"3m" | "6m" | "ytd">("6m");
const mrrChartData = React.useMemo(() => {
if (mrrRange === "3m") return channelRevenueData.slice(-3);
if (mrrRange === "ytd") return channelRevenueData.slice(0, 4);
return channelRevenueData;
}, [mrrRange]);
return (
<div className="flex min-h-0 min-w-0 flex-col rounded-xl border bg-card xl:w-[410px]">
<div className="flex min-h-14 shrink-0 flex-col gap-2 border-b px-4 py-2 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="flex items-center gap-2.5">
<Button
variant="outline"
size="icon"
className="size-7 shrink-0 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={mrrRange}
onValueChange={(v: string) => setMrrRange(v as "3m" | "6m" | "ytd")}
>
<SelectTrigger
size="sm"
className="h-8 w-full text-xs sm:w-[148px]"
aria-label="بازه زمانی درآمد ماهانه تکرارشونده"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end" position="popper">
<SelectItem value="3m">۳ ماه اخیر</SelectItem>
<SelectItem value="6m">۶ ماه اخیر</SelectItem>
<SelectItem value="ytd">از ابتدای سال</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 p-4 sm:p-5">
<div className="flex w-full flex-wrap items-center justify-center gap-x-4 gap-y-2 sm:gap-x-5">
{[
{ label: channelChartConfig.starter.label, color: palette.primary },
{ label: channelChartConfig.pro.label, color: palette.secondary.light },
{
label: channelChartConfig.enterprise.label,
color: palette.tertiary.light,
},
{ label: channelChartConfig.addOns.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="text-[10px] text-muted-foreground sm:text-xs">
{item.label}
</span>
</div>
))}
</div>
<div className="flex w-full justify-center">
<div className="h-[240px] w-full max-w-[min(100%,380px)] min-w-0 sm:h-[280px]">
<ChartContainer
config={channelChartConfig}
className="h-full w-full"
>
<BarChart
layout="vertical"
data={mrrChartData}
barSize={24}
margin={{ top: 8, right: 12, bottom: 8, left: 8 }}
>
<XAxis
type="number"
axisLine={false}
tickLine={false}
tick={chartCurrencyTickProps}
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="starter"
stackId="channel"
fill="var(--color-starter)"
radius={[4, 0, 0, 4]}
/>
<Bar
dataKey="pro"
stackId="channel"
fill="var(--color-pro)"
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="enterprise"
stackId="channel"
fill="var(--color-enterprise)"
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="addOns"
stackId="channel"
fill="var(--color-addOns)"
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ChartContainer>
</div>
</div>
</div>
</div>
);
};
// ============================================================================
// Total Revenue Chart (from Dashboard9)
// ============================================================================
function RevenueTooltip({
active,
payload,
label,
colors,
variant,
primaryLabel,
secondaryLabel,
comparisonPhrase,
}: TooltipContentProps & {
colors: { primary: string; secondary: string };
variant: "year" | "month";
primaryLabel: string;
secondaryLabel: string;
comparisonPhrase: string;
}) {
if (!active || !payload?.length) return null;
const primaryKey = variant === "year" ? "thisYear" : "current";
const secondaryKey = variant === "year" ? "prevYear" : "previous";
const primaryVal = payload.find((p) => p.dataKey === primaryKey)?.value ?? 0;
const secondaryVal =
payload.find((p) => p.dataKey === secondaryKey)?.value ?? 0;
const diff = Number(primaryVal) - Number(secondaryVal);
const percentage = secondaryVal ? diff / Number(secondaryVal) : 0;
const currentYear = new Date().getFullYear().toLocaleString("fa-IR");
const formattedDelta = `${diff >= 0 ? "+" : ""}${percentFormatter.format(
Math.abs(percentage),
)}`;
const title =
variant === "year"
? `${label}, ${currentYear}`
: `${label} · ${currentYear}`;
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">
{title}
</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: colors.primary }}
/>
<span className="text-[10px] text-muted-foreground sm:text-sm">
{primaryLabel}:
</span>
<span className="text-[10px] font-medium text-foreground tabular-nums sm:text-sm">
{currencyFormatter.format(Number(primaryVal))}
</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: colors.secondary }}
/>
<span className="text-[10px] text-muted-foreground sm:text-sm">
{secondaryLabel}:
</span>
<span className="text-[10px] font-medium text-foreground tabular-nums sm:text-sm">
{currencyFormatter.format(Number(secondaryVal))}
</span>
</div>
<div className="mt-1 border-t border-border pt-1">
<span
className={cn(
"text-[10px] font-medium tabular-nums sm:text-xs",
diff >= 0 ? "text-emerald-500" : "text-red-500",
)}
>
{formattedDelta} {comparisonPhrase}
</span>
</div>
</div>
</div>
);
}
const TotalRevenueChart = () => {
const [timeline, setTimeline] = React.useState<"year" | "month">("year");
const isYear = timeline === "year";
const chartData = isYear ? fullYearData : revenueMonthTimelineData;
const xKey = isYear ? "month" : "label";
const lineKey = isYear ? "thisYear" : "current";
const areaKey = isYear ? "prevYear" : "previous";
const chartConfig = isYear ? revenueFlowChartConfig : revenueMonthChartConfig;
const lineColorVar = isYear
? "ارزش افزوده(--color-thisYear)"
: "ارزش افزوده(--color-current)";
const areaColorVar = isYear
? "ارزش افزوده(--color-prevYear)"
: "ارزش افزوده(--color-previous)";
const totalRevenue = isYear
? fullYearData.reduce((acc, item) => acc + item.thisYear, 0)
: revenueMonthTimelineData.reduce((acc, item) => acc + item.current, 0);
const legendDotClass = "size-2 rounded-full sm:size-2.5";
const primaryLegend = isYear ? "امسال" : "این ماه";
const secondaryLegend = isYear ? "سال قبل" : "ماه قبل";
const subtitle = isYear
? "امسال در برابر سال قبل"
: "این ماه در برابر ماه قبل";
return (
<div className="flex min-w-0 flex-1 flex-col rounded-xl border bg-card">
<div className="flex min-h-14 flex-col gap-2 border-b px-4 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-5">
<div className="flex items-center gap-2.5">
<Button
variant="outline"
size="icon"
className="size-7 shrink-0 sm:size-8"
aria-label="درآمد کل"
>
<LineChartIcon
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 min-w-0 flex-nowrap items-center justify-start gap-3 sm:justify-end sm:gap-4">
<div className="hidden shrink-0 flex-nowrap items-center gap-3 lg:flex lg:gap-4">
<div className="flex items-center gap-1.5 whitespace-nowrap">
<div
className={legendDotClass}
style={{ backgroundColor: palette.primary }}
/>
<span className="text-[10px] text-muted-foreground sm:text-xs">
{primaryLegend}
</span>
</div>
<div className="flex items-center gap-1.5 whitespace-nowrap">
<div
className={legendDotClass}
style={{ backgroundColor: palette.secondary.light }}
/>
<span className="text-[10px] text-muted-foreground sm:text-xs">
{secondaryLegend}
</span>
</div>
</div>
<Select
value={timeline}
onValueChange={(v: string) => setTimeline(v as "year" | "month")}
>
<SelectTrigger
size="sm"
className="h-8 w-[130px] shrink-0 text-xs sm:w-[140px]"
aria-label="بازه درآمد کل"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end" position="popper">
<SelectItem value="month">این ماه</SelectItem>
<SelectItem value="year">امسال</SelectItem>
</SelectContent>
</Select>
</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={cn(
"text-xl leading-tight font-semibold tracking-tight sm:text-xl",
numericMonoClass,
)}
>
{currencyFormatter.format(totalRevenue)}
</p>
<p className="text-[10px] tracking-wider text-muted-foreground uppercase sm:text-xs">
{subtitle}
</p>
</div>
<div className="h-[200px] w-full min-w-0 sm:h-[240px] lg:h-[280px]">
<ChartContainer config={chartConfig} className="h-full w-full">
<ComposedChart data={chartData as any[]}>
<CartesianGrid strokeDasharray="0" vertical={false} />
<XAxis
dataKey={xKey}
axisLine={false}
tickLine={false}
tick={{ fontSize: 10 }}
dy={8}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ ...chartCurrencyTickProps, dx: -5 }}
tickFormatter={(v) => compactCurrencyFormatter.format(v)}
width={40}
/>
<Tooltip
content={(props) => (
<RevenueTooltip
{...props}
variant={isYear ? "year" : "month"}
primaryLabel={primaryLegend}
secondaryLabel={secondaryLegend}
comparisonPhrase={
isYear ? "نسبت به سال قبل" : "نسبت به دوره مشابه ماه قبل"
}
colors={{
primary: lineColorVar,
secondary: areaColorVar,
}}
/>
)}
cursor={{ strokeOpacity: 0.2 }}
/>
<Line
type="linear"
dataKey={lineKey}
stroke={lineColorVar}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
dot={{ fill: lineColorVar, strokeWidth: 0, r: 2 }}
activeDot={{ r: 3.5, fill: lineColorVar }}
/>
<Area
type="linear"
dataKey={areaKey}
stroke={areaColorVar}
strokeWidth={1.5}
strokeOpacity={0.5}
fill={areaColorVar}
fillOpacity={0.08}
/>
</ComposedChart>
</ChartContainer>
</div>
</div>
</div>
);
};
// ============================================================================
// Mini Chart Cards
// ============================================================================
const standardChartMenuLabels = [
"مشاهده جزئیات",
"خروجی جدول",
"باز کردن در تحلیل فروش",
] as const;
type MiniChartCardProps = {
title: string;
value: string;
changePercent: number;
isPositive: boolean;
helper: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
legend?: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
toolbar?: React.ReactNode;
menuLabels?: readonly string[];
};
const MiniChartCard = ({
title,
value,
changePercent,
isPositive,
helper,
icon: Icon,
legend,
footer,
children,
toolbar,
menuLabels = standardChartMenuLabels,
}: MiniChartCardProps) => {
return (
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4 sm:p-5">
<div className="flex min-h-[64px] items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-start gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border bg-muted/40">
<Icon className="size-4 text-muted-foreground" aria-hidden="true" />
</div>
<div className="min-w-0 space-y-1">
<p className="text-xs font-medium text-muted-foreground">{title}</p>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span
className={cn(
"text-lg font-semibold text-foreground",
numericMonoClass,
)}
>
{value}
</span>
<span
className={cn(
"flex items-center gap-1 text-xs font-medium tabular-nums",
isPositive ? "text-emerald-600" : "text-red-600",
)}
>
{isPositive ? (
<ArrowUpRight className="size-3" aria-hidden="true" />
) : (
<ArrowDownRight className="size-3" aria-hidden="true" />
)}
{Math.round(Math.abs(changePercent)).toLocaleString("fa-IR")}٪
</span>
<span className="text-[10px] text-muted-foreground sm:text-xs">
{helper}
</span>
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{toolbar}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label={`عملیات ${title}`}
>
<MoreHorizontal className="size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{menuLabels.map((label) => (
<DropdownMenuItem key={label}>{label}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{legend && (
<div className="flex min-h-[20px] items-center gap-4 overflow-x-auto text-[10px] whitespace-nowrap text-muted-foreground sm:text-xs">
{legend}
</div>
)}
<div className="min-h-[144px] w-full flex-1">{children}</div>
{footer ? (
<div className="min-h-[20px] text-[10px] text-muted-foreground sm:text-xs">
{footer}
</div>
) : (
<div className="flex min-h-[20px] items-center justify-between text-[10px] text-muted-foreground sm:text-xs">
<span>{chartDates[0]}</span>
<span>{chartDates[chartDates.length - 1]}</span>
</div>
)}
</div>
);
};
const renderActiveShape = (props: unknown) => {
const p = props as {
cx: number;
cy: number;
innerRadius: number;
outerRadius: number;
startAngle: number;
endAngle: number;
fill: string;
};
return (
<g>
<Sector
cx={p.cx}
cy={p.cy}
innerRadius={p.innerRadius}
outerRadius={p.outerRadius + 6}
startAngle={p.startAngle}
endAngle={p.endAngle}
fill={p.fill}
/>
</g>
);
};
const MiniChartsSection = () => {
const [activeSlice, setActiveSlice] = React.useState<number | null>(null);
const [planWindow, setPlanWindow] = React.useState<"7d" | "28d" | "90d">(
"28d",
);
const [arpuRange, setArpuRange] = React.useState<"28d" | "90d">("28d");
const [dauWindow, setDauWindow] = React.useState<"14d" | "28d">("28d");
const planMix = channelMixByWindow[planWindow];
const topPlan = planMix.reduce((top, item) =>
item.value > top.value ? item : top,
);
const arpuChartData = arpuRange === "90d" ? aovData90 : aovData;
const arpuRefAverage = arpuRange === "90d" ? aovAverage90 : aovAverage;
const arpuHeadline = Math.round(
(arpuRange === "90d" ? aovValues90 : aovValues).reduce((a, b) => a + b, 0) /
(arpuRange === "90d" ? aovValues90.length : aovValues.length),
);
const dauChartData = React.useMemo(
() => averageSalesData.slice(-(dauWindow === "14d" ? 14 : 28)),
[dauWindow],
);
const dauHeadline = Math.round(
dauChartData.reduce((acc, row) => acc + row.thisMonth, 0) /
Math.max(dauChartData.length, 1),
);
const planMixCaption =
planWindow === "7d"
? "۷ روز اخیر"
: planWindow === "90d"
? "۹۰ روز اخیر"
: "۲۸ روز اخیر";
const arpuToolbar = (
<Select
value={arpuRange}
onValueChange={(v: string) => setArpuRange(v as "28d" | "90d")}
>
<SelectTrigger
size="sm"
className="h-8 w-[112px] text-xs"
aria-label="بازه میانگین درآمد هر کاربر"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end" position="popper">
<SelectItem value="28d">۲۸ روز اخیر</SelectItem>
<SelectItem value="90d">۹۰ روز اخیر</SelectItem>
</SelectContent>
</Select>
);
const dauToolbar = (
<Select
value={dauWindow}
onValueChange={(v: string) => setDauWindow(v as "14d" | "28d")}
>
<SelectTrigger
size="sm"
className="h-8 w-[104px] text-xs"
aria-label="بازه اعضای تیم فعال روزانه"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end" position="popper">
<SelectItem value="14d">۱۴ روز اخیر</SelectItem>
<SelectItem value="28d">۲۸ روز اخیر</SelectItem>
</SelectContent>
</Select>
);
return (
<div className="grid gap-4 lg:grid-cols-3">
<MiniChartCard
title="ARPU"
value={currencyFormatter.format(arpuHeadline)}
changePercent={2.4}
isPositive
helper="نسبت به ماه قبل"
icon={CircleDollarSign}
toolbar={arpuToolbar}
footer={
<>
{arpuChartData[0]?.date} –{" "}
{arpuChartData[arpuChartData.length - 1]?.date}
</>
}
>
<ChartContainer config={aovChartConfig} className="h-full w-full">
<BarChart data={arpuChartData}>
<CartesianGrid strokeDasharray="4 4" vertical={false} />
<XAxis dataKey="date" hide />
<YAxis hide />
<ReferenceLine
y={arpuRefAverage}
stroke="var(--color-reference)"
strokeDasharray="4 4"
/>
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null;
const value = payload[0].value as number;
const diff = value - arpuRefAverage;
const diffPercent = Math.round((diff / arpuRefAverage) * 100);
return (
<div className="rounded-lg border border-border bg-popover px-3 py-2 shadow-lg">
<p className="mb-1 text-xs font-medium text-foreground">
{label}
</p>
<p className="text-sm font-semibold text-foreground tabular-nums">
{currencyFormatter.format(value)}
</p>
<p className="text-[10px] text-muted-foreground">
{diff >= 0 ? "+" : ""}
{diffPercent.toLocaleString("fa-IR")}٪ نسبت به میانگین
</p>
</div>
);
}}
cursor={{ fillOpacity: 0.05 }}
/>
<Bar
dataKey="value"
fill="var(--color-value)"
fillOpacity={1}
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
</MiniChartCard>
<MiniChartCard
title="اعضای تیم فعال روزانه"
value={numberFormatter.format(dauHeadline)}
changePercent={1.3}
isPositive
helper="نسبت به ماه قبل"
icon={BarChart3}
toolbar={dauToolbar}
legend={
<>
<span className="flex items-center gap-1">
<span
className="size-2 rounded-full"
style={{ backgroundColor: palette.primary }}
/>
{averageSalesChartConfig.thisMonth.label}
</span>
<span className="flex items-center gap-1">
<span
className="size-2 rounded-full"
style={{ backgroundColor: palette.secondary.light }}
/>
{averageSalesChartConfig.lastMonth.label}
</span>
</>
}
footer={
<>
{dauChartData[0]?.date} –{" "}
{dauChartData[dauChartData.length - 1]?.date}
</>
}
>
<ChartContainer
config={averageSalesChartConfig}
className="h-full w-full"
>
<LineChart data={dauChartData}>
<XAxis dataKey="date" hide />
<YAxis hide />
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null;
const thisMonth = payload.find((p) => p.dataKey === "thisMonth")
?.value as number;
const lastMonth = payload.find((p) => p.dataKey === "lastMonth")
?.value as number;
const diff = thisMonth - lastMonth;
const diffPercent = Math.round((diff / lastMonth) * 100);
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="flex flex-col gap-1">
<div className="flex items-center gap-1.5 text-[10px]">
<span
className="size-2 rounded-full"
style={{ backgroundColor: palette.primary }}
/>
<span className="text-muted-foreground">
{averageSalesChartConfig.thisMonth.label}:
</span>
<span className="font-medium text-foreground tabular-nums">
{numberFormatter.format(thisMonth)}
</span>
</div>
<div className="flex items-center gap-1.5 text-[10px]">
<span
className="size-2 rounded-full"
style={{ backgroundColor: palette.secondary.light }}
/>
<span className="text-muted-foreground">
{averageSalesChartConfig.lastMonth.label}:
</span>
<span className="font-medium text-foreground tabular-nums">
{numberFormatter.format(lastMonth)}
</span>
</div>
</div>
<p className="mt-1.5 text-[10px] text-muted-foreground">
{diff >= 0 ? "+" : ""}
{diffPercent.toLocaleString("fa-IR")}٪ تغییر
</p>
</div>
);
}}
cursor={{ strokeOpacity: 0.2 }}
/>
<Line
type="monotone"
dataKey="thisMonth"
stroke="var(--color-thisMonth)"
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="lastMonth"
stroke="var(--color-lastMonth)"
strokeWidth={2}
strokeOpacity={0.5}
dot={false}
/>
</LineChart>
</ChartContainer>
</MiniChartCard>
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4 sm:p-5">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border bg-muted/40">
<Layers
className="size-4 text-muted-foreground"
aria-hidden="true"
/>
</div>
<span className="text-sm font-medium">اعضای تیم بر اساس پلن</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<Select
value={planWindow}
onValueChange={(v: string) =>
setPlanWindow(v as "7d" | "28d" | "90d")
}
>
<SelectTrigger
size="sm"
className="h-8 w-[118px] text-xs"
aria-label="بازه اعضای تیم بر اساس پلن"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end" position="popper">
<SelectItem value="7d">۷ روز اخیر</SelectItem>
<SelectItem value="28d">۲۸ روز اخیر</SelectItem>
<SelectItem value="90d">۹۰ روز اخیر</SelectItem>
</SelectContent>
</Select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label="عملیات اعضای تیم بر اساس پلن"
>
<MoreHorizontal className="size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{standardChartMenuLabels.map((label) => (
<DropdownMenuItem key={label}>{label}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex flex-1 items-center justify-center gap-4 sm:gap-6">
<div className="relative size-[170px] shrink-0 sm:size-[190px]">
<ChartContainer
config={channelMixChartConfig}
className="h-full w-full"
>
<PieChart>
<Pie
data={planMix}
dataKey="value"
nameKey="name"
innerRadius="38%"
outerRadius="68%"
paddingAngle={3}
strokeWidth={0}
{...({
activeIndex: activeSlice !== null ? activeSlice : undefined,
} as any)}
activeShape={renderActiveShape}
onMouseEnter={(_: unknown, index: number) =>
setActiveSlice(index)
}
onMouseLeave={() => setActiveSlice(null)}
>
{planMix.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
</PieChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className={cn("text-lg font-semibold", numericMonoClass)}>
{Math.round(topPlan.value).toLocaleString("fa-IR")}٪
</span>
<span className="text-[9px] text-muted-foreground">
{topPlan.name}
</span>
</div>
</div>
<div className="flex flex-col gap-2.5">
{planMix.map((item, index) => (
<div
key={item.name}
className={cn(
"flex cursor-pointer items-center gap-2.5 transition-opacity",
activeSlice !== null && activeSlice !== index && "opacity-50",
)}
onMouseEnter={() => setActiveSlice(index)}
onMouseLeave={() => setActiveSlice(null)}
>
<div
className="h-4 w-1 shrink-0 rounded-sm"
style={{ backgroundColor: item.color }}
/>
<span className="flex-1 text-xs text-muted-foreground">
{item.name}
</span>
<span className="text-xs font-semibold tabular-nums">
{Math.round(item.value).toLocaleString("fa-IR")}٪
</span>
</div>
))}
</div>
</div>
<div className="text-[10px] text-muted-foreground sm:text-xs">
{planMixCaption}
</div>
</div>
</div>
);
};
// ============================================================================
// Dashboard Content
// ============================================================================
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"
>
<WelcomeSection />
<StatsCards />
<div className="flex flex-col gap-4 sm:gap-6 xl:flex-row xl:items-stretch">
<TotalRevenueChart />
<ChannelRevenueChart />
</div>
<MiniChartsSection />
</div>
);
};
// ============================================================================
// Main Dashboard Component
// ============================================================================
const Dashboard1 = ({ className }: { className?: string }) => {
return (
<TooltipProvider>
<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>
</TooltipProvider>
);
};
export { Dashboard1 };