Files
reui-blocks/blocks/dashboard1.tsx
T

1972 lines
65 KiB
TypeScript
Raw Normal View History

"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 };