Files
reui-blocks/blocks/chart-group15.tsx
T

744 lines
25 KiB
TypeScript
Raw 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 {
addDays,
differenceInDays,
format as formatDate,
startOfDay,
subDays,
} from "date-fns";
import { faIR } from "date-fns/locale";
import {
CalendarIcon,
ChevronLeft,
ChevronRight as ChevronRightIcon,
Download,
MoreHorizontal,
RefreshCw,
Settings,
TrendingDown,
TrendingUp,
} from "lucide-react";
import { useMemo, useState } from "react";
import type { DateRange } from "react-day-picker";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
ComposedChart,
Line,
LineChart,
Pie,
PieChart,
XAxis,
YAxis,
} from "recharts";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
interface ChartGroup15Props {
className?: string;
}
const seededRandom = (seed: number) => {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
};
const currencyFormatter = new Intl.NumberFormat("fa-IR", {
style: "currency",
currency: "IRR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
const compactCurrencyFormatter = new Intl.NumberFormat("fa-IR", {
style: "currency",
currency: "IRR",
notation: "compact",
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
const numberFormatter = new Intl.NumberFormat("fa-IR", {
maximumFractionDigits: 0,
});
const oneDecimalFormatter = new Intl.NumberFormat("fa-IR", {
maximumFractionDigits: 1,
minimumFractionDigits: 1,
});
const percentChangeFormatter = new Intl.NumberFormat("fa-IR", {
maximumFractionDigits: 1,
minimumFractionDigits: 1,
signDisplay: "exceptZero",
});
const formatPercent = (value: number) =>
`${oneDecimalFormatter.format(value)}٪`;
const formatHour = (hour: number) =>
`${new Intl.NumberFormat("fa-IR", {
minimumIntegerDigits: 2,
useGrouping: false,
}).format(hour)}:00`;
const generateDataForRange = (from: Date, to: Date) => {
const data = [];
let currentDate = new Date(from);
while (currentDate <= to) {
const seed = currentDate.getTime() / 86400000;
const baseRevenue = 10000 + Math.sin(seed / 7) * 3000;
const baseCost = 6000 + Math.sin(seed / 7) * 1500;
data.push({
date: new Date(currentDate),
dateStr: formatDate(currentDate, "d MMM", { locale: faIR }),
revenue: Math.floor(baseRevenue + seededRandom(seed * 1) * 2000),
cost: Math.floor(baseCost + seededRandom(seed * 2) * 1000),
profit: Math.floor(
baseRevenue - baseCost + seededRandom(seed * 3) * 1000,
),
orders: Math.floor(seededRandom(seed * 4) * 80) + 40,
aov: Math.floor(seededRandom(seed * 5) * 50) + 100,
newCustomers: Math.floor(seededRandom(seed * 6) * 30) + 10,
returning: Math.floor(seededRandom(seed * 7) * 50) + 30,
});
currentDate = addDays(currentDate, 1);
}
return data;
};
const hourlyData = Array.from({ length: 24 }, (_, i) => {
const seed = i * 1000;
return {
hour: formatHour(i),
visitors:
Math.floor(seededRandom(seed) * 500) + (i >= 9 && i <= 17 ? 300 : 50),
conversions:
Math.floor(seededRandom(seed + 1) * 30) + (i >= 9 && i <= 17 ? 15 : 2),
};
});
const deviceData = [
{ name: "دسکتاپ", value: 58, fill: "var(--chart-1)" },
{ name: "موبایل", value: 32, fill: "var(--chart-2)" },
{ name: "تبلت", value: 10, fill: "var(--chart-3)" },
];
const deviceConfig = {
دسکتاپ: { label: "دسکتاپ", color: "var(--chart-1)" },
موبایل: { label: "موبایل", color: "var(--chart-2)" },
تبلت: { label: "تبلت", color: "var(--chart-3)" },
} satisfies ChartConfig;
const revenueConfig = {
revenue: { label: "درآمد", color: "var(--chart-1)" },
cost: { label: "هزینه", color: "var(--chart-2)" },
profit: { label: "سود", color: "var(--chart-3)" },
orders: { label: "سفارش‌ها", color: "var(--chart-1)" },
} satisfies ChartConfig;
const customerConfig = {
newCustomers: { label: "جدید", color: "var(--chart-1)" },
returning: { label: "بازگشتی", color: "var(--chart-2)" },
} satisfies ChartConfig;
const hourlyConfig = {
visitors: { label: "بازدیدکنندگان", color: "var(--chart-1)" },
conversions: { label: "تبدیل‌ها", color: "var(--chart-3)" },
} satisfies ChartConfig;
const ChartGroup15 = ({ className }: ChartGroup15Props) => {
const [dateRange, setDateRange] = useState<DateRange | undefined>({
from: subDays(new Date(), 29),
to: new Date(),
});
const [chartView, setChartView] = useState<"revenue" | "profit" | "orders">(
"revenue",
);
const filteredData = useMemo(() => {
if (!dateRange?.from) {
return generateDataForRange(subDays(new Date(), 29), new Date());
}
const from = startOfDay(dateRange.from);
const to = dateRange.to ? startOfDay(dateRange.to) : from;
return generateDataForRange(from, to);
}, [dateRange]);
const stats = useMemo(() => {
const totalRevenue = filteredData.reduce(
(sum, item) => sum + item.revenue,
0,
);
const totalCost = filteredData.reduce((sum, item) => sum + item.cost, 0);
const totalProfit = filteredData.reduce(
(sum, item) => sum + item.profit,
0,
);
const totalOrders = filteredData.reduce(
(sum, item) => sum + item.orders,
0,
);
const profitMargin =
totalRevenue > 0 ? (totalProfit / totalRevenue) * 100 : 0;
return {
totalRevenue,
totalCost,
totalProfit,
totalOrders,
profitMargin,
};
}, [filteredData]);
const daysDiff =
dateRange?.from && dateRange?.to
? differenceInDays(dateRange.to, dateRange.from) + 1
: 30;
const navigateRange = (direction: "prev" | "next") => {
if (!dateRange?.from || !dateRange?.to) return;
const shift = direction === "prev" ? -daysDiff : daysDiff;
setDateRange({
from: addDays(dateRange.from, shift),
to: addDays(dateRange.to, shift),
});
};
const periodSubtitle =
dateRange?.from && dateRange?.to
? ` · ${formatDate(dateRange.from, "d MMM", { locale: faIR })} – ${formatDate(dateRange.to, "d MMM y", { locale: faIR })}`
: "";
const statRows = [
{
label: "درآمد",
value: currencyFormatter.format(stats.totalRevenue),
change: 12.5,
},
{
label: "هزینه",
value: currencyFormatter.format(stats.totalCost),
change: 8.2,
},
{
label: "سود",
value: currencyFormatter.format(stats.totalProfit),
change: 18.3,
},
{
label: "سفارش‌ها",
value: numberFormatter.format(stats.totalOrders),
change: 5.7,
},
{
label: "حاشیه سود",
value: formatPercent(stats.profitMargin),
change: 2.1,
},
];
const currencyTooltip = (
<ChartTooltipContent
formatter={(value) => currencyFormatter.format(Number(value))}
/>
);
const numberTooltip = (
<ChartTooltipContent
formatter={(value) => numberFormatter.format(Number(value))}
/>
);
return (
<section
dir="rtl"
lang="fa"
className={cn("py-32 text-start", className)}
>
<div className="container space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-xl font-bold tracking-tight">
تحلیل کسب‌وکار
</h2>
<p className="text-muted-foreground">
بازه {numberFormatter.format(daysDiff)} روزه انتخاب شده
{periodSubtitle}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center rounded-lg border">
<Button
variant="ghost"
size="icon"
className="rounded-e-none"
onClick={() => navigateRange("prev")}
aria-label="بازه قبلی"
>
<ChevronRightIcon className="size-4" />
</Button>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
className="rounded-none border-x px-3 font-normal"
>
<CalendarIcon className="ms-2 size-4" aria-hidden="true" />
{dateRange?.from
? formatDate(dateRange.from, "d MMM", { locale: faIR })
: "شروع"}{" "}
–{" "}
{dateRange?.to
? formatDate(dateRange.to, "d MMM", { locale: faIR })
: "پایان"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="center">
<Calendar
mode="range"
numberOfMonths={2}
locale={faIR}
selected={dateRange}
onSelect={setDateRange}
/>
</PopoverContent>
</Popover>
<Button
variant="ghost"
size="icon"
className="rounded-s-none"
onClick={() => navigateRange("next")}
aria-label="بازه بعدی"
>
<ChevronLeft className="size-4" />
</Button>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" aria-label="گزینه‌های بیشتر">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem>
<Download className="ms-2 size-4" />
خروجی جدول
</DropdownMenuItem>
<DropdownMenuItem>
<RefreshCw className="ms-2 size-4" />
به‌روزرسانی
</DropdownMenuItem>
<DropdownMenuItem>
<Settings className="ms-2 size-4" />
تنظیمات
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{statRows.map((stat, index) => (
<Card key={index} className="text-start">
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">{stat.label}</p>
<div className="flex items-baseline justify-between gap-2">
<p className="text-xl font-bold tabular-nums">{stat.value}</p>
<span
className={cn(
"flex shrink-0 items-center text-sm tabular-nums",
stat.change >= 0 ? "text-green-600" : "text-red-600",
)}
>
{stat.change >= 0 ? (
<TrendingUp className="ms-1 size-3" />
) : (
<TrendingDown className="ms-1 size-3" />
)}
{percentChangeFormatter.format(stat.change)}٪
</span>
</div>
</CardContent>
</Card>
))}
</div>
<div className="grid gap-4 lg:grid-cols-3">
<Card className="text-start lg:col-span-2">
<Tabs
value={chartView}
onValueChange={(v) => setChartView(v as typeof chartView)}
dir="rtl"
>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div>
<CardTitle className="text-base">مرور عملکرد</CardTitle>
<CardDescription>
روند درآمد، هزینه و سود
</CardDescription>
</div>
<TabsList className="h-8">
<TabsTrigger value="revenue" className="text-xs">
درآمد
</TabsTrigger>
<TabsTrigger value="profit" className="text-xs">
سود
</TabsTrigger>
<TabsTrigger value="orders" className="text-xs">
سفارش‌ها
</TabsTrigger>
</TabsList>
</CardHeader>
<CardContent>
<TabsContent value="revenue" className="mt-0">
<ChartContainer
config={revenueConfig}
className="h-72 w-full"
>
<AreaChart
data={filteredData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient
id="revGrad15"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--color-revenue)"
stopOpacity={0.3}
/>
<stop
offset="100%"
stopColor="var(--color-revenue)"
stopOpacity={0}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="dateStr"
axisLine={false}
tickLine={false}
tickMargin={8}
fontSize={11}
interval="preserveStartEnd"
/>
<YAxis
axisLine={false}
tickLine={false}
tickMargin={8}
fontSize={10}
width={52}
tickFormatter={(v) =>
compactCurrencyFormatter.format(Number(v))
}
/>
<ChartTooltip content={currencyTooltip} />
<ChartLegend content={<ChartLegendContent />} />
<Area
type="monotone"
dataKey="revenue"
stroke="var(--color-revenue)"
strokeWidth={2}
fill="url(#revGrad15)"
/>
<Line
type="monotone"
dataKey="cost"
stroke="var(--color-cost)"
strokeWidth={2}
dot={false}
strokeDasharray="4 4"
/>
</AreaChart>
</ChartContainer>
</TabsContent>
<TabsContent value="profit" className="mt-0">
<ChartContainer
config={revenueConfig}
className="h-72 w-full"
>
<BarChart
data={filteredData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="dateStr"
axisLine={false}
tickLine={false}
tickMargin={8}
fontSize={11}
interval="preserveStartEnd"
/>
<YAxis
axisLine={false}
tickLine={false}
tickMargin={8}
fontSize={10}
width={52}
tickFormatter={(v) =>
compactCurrencyFormatter.format(Number(v))
}
/>
<ChartTooltip content={currencyTooltip} />
<Bar
dataKey="profit"
fill="var(--color-profit)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
</TabsContent>
<TabsContent value="orders" className="mt-0">
<ChartContainer
config={revenueConfig}
className="h-72 w-full"
>
<LineChart
data={filteredData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="dateStr"
axisLine={false}
tickLine={false}
tickMargin={8}
fontSize={11}
interval="preserveStartEnd"
/>
<YAxis
axisLine={false}
tickLine={false}
tickMargin={8}
fontSize={10}
width={40}
tickFormatter={(v) => numberFormatter.format(Number(v))}
/>
<ChartTooltip content={numberTooltip} />
<Line
type="monotone"
dataKey="orders"
stroke="var(--color-revenue)"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ChartContainer>
</TabsContent>
</CardContent>
</Tabs>
</Card>
<Card className="text-start">
<CardHeader className="pb-2">
<CardTitle className="text-base">تفکیک دستگاه</CardTitle>
<CardDescription>ترافیک بر اساس نوع دستگاه</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
<ChartContainer
config={deviceConfig}
className="mx-auto aspect-square h-40"
>
<PieChart>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
hideLabel
nameKey="name"
formatter={(value) => formatPercent(Number(value))}
/>
}
/>
<Pie
data={deviceData}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={70}
paddingAngle={3}
dataKey="value"
nameKey="name"
strokeWidth={0}
/>
</PieChart>
</ChartContainer>
<div className="w-full space-y-2">
{deviceData.map((item, index) => (
<div
key={index}
className="flex items-center justify-between gap-2"
>
<div className="flex items-center gap-2">
<div
className="size-3 shrink-0 rounded-sm"
style={{ backgroundColor: item.fill }}
/>
<span className="text-sm">{item.name}</span>
</div>
<span className="text-sm font-medium tabular-nums">
{formatPercent(item.value)}
</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="text-start">
<CardHeader className="pb-2">
<CardTitle className="text-base">انواع مشتری</CardTitle>
<CardDescription>مشتریان جدید در برابر بازگشتی</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={customerConfig} className="h-48 w-full">
<BarChart
data={filteredData.slice(-14)}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="dateStr"
axisLine={false}
tickLine={false}
tickMargin={8}
fontSize={10}
/>
<YAxis
axisLine={false}
tickLine={false}
tickMargin={8}
fontSize={10}
width={36}
tickFormatter={(v) => numberFormatter.format(Number(v))}
/>
<ChartTooltip content={numberTooltip} />
<Bar
dataKey="newCustomers"
stackId="a"
fill="var(--color-newCustomers)"
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="returning"
stackId="a"
fill="var(--color-returning)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
<Card className="text-start lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-base">ترافیک ساعتی</CardTitle>
<CardDescription>
بازدیدکنندگان و تبدیل‌ها به تفکیک ساعت (امروز)
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={hourlyConfig} className="h-48 w-full">
<ComposedChart
data={hourlyData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="hour"
axisLine={false}
tickLine={false}
tickMargin={8}
fontSize={10}
interval={2}
/>
<YAxis
yAxisId="left"
axisLine={false}
tickLine={false}
tickMargin={8}
fontSize={10}
width={40}
tickFormatter={(v) => numberFormatter.format(Number(v))}
/>
<YAxis
yAxisId="right"
orientation="right"
axisLine={false}
tickLine={false}
tickMargin={8}
fontSize={10}
width={36}
tickFormatter={(v) => numberFormatter.format(Number(v))}
/>
<ChartTooltip content={numberTooltip} />
<Bar
yAxisId="left"
dataKey="visitors"
fill="var(--color-visitors)"
radius={[2, 2, 0, 0]}
opacity={0.8}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="conversions"
stroke="var(--color-conversions)"
strokeWidth={2}
dot={false}
/>
</ComposedChart>
</ChartContainer>
</CardContent>
</Card>
</div>
</div>
</section>
);
};
export { ChartGroup15 };