feat(admin): add analytics dashboard UI
This commit is contained in:
592
src/views/AdminDashboard.tsx
Normal file
592
src/views/AdminDashboard.tsx
Normal file
@@ -0,0 +1,592 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
CalendarDays,
|
||||
Heart,
|
||||
Landmark,
|
||||
LineChart as LineChartIcon,
|
||||
Save,
|
||||
TrendingUp,
|
||||
UsersRound,
|
||||
WalletCards,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Line,
|
||||
LineChart,
|
||||
Scatter,
|
||||
ScatterChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
ZAxis,
|
||||
} from "recharts";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
AdminDashboardAnalyticsSchema,
|
||||
AnalyticsPointSchema,
|
||||
AnalyticsPostPopularitySchema,
|
||||
AnalyticsTrendPointSchema,
|
||||
EventListItemSchema,
|
||||
} from "@/lib/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { formatNumberPersian, formatToman, resolveErrorMessage, toPersianDigits } from "@/lib/utils";
|
||||
|
||||
const chartColors = [
|
||||
"hsl(var(--primary))",
|
||||
"hsl(var(--chart-2, 173 58% 39%))",
|
||||
"hsl(var(--chart-3, 197 37% 24%))",
|
||||
"hsl(var(--chart-4, 43 74% 66%))",
|
||||
"hsl(var(--chart-5, 27 87% 67%))",
|
||||
];
|
||||
|
||||
type DashboardFilters = {
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
event_id: string;
|
||||
granularity: "auto" | "day" | "week" | "month";
|
||||
};
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
icon: Icon,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
description: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="flex items-center justify-between gap-4 p-5">
|
||||
<div className="space-y-2 text-right">
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
<p className="text-2xl font-black tracking-tight">{value}</p>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-primary/10 p-3 text-primary">
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyChart({ label = "دادهای برای نمایش وجود ندارد." }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex h-[240px] items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VerticalBarChart({ data, color = "var(--color-value)" }: { data: AnalyticsPointSchema[]; color?: string }) {
|
||||
if (!data.length) return <EmptyChart />;
|
||||
return (
|
||||
<ChartContainer config={{ value: { label: "تعداد", color } }} className="h-[280px] w-full">
|
||||
<BarChart data={data} layout="vertical" margin={{ left: 8, right: 8 }}>
|
||||
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
||||
<XAxis type="number" tickFormatter={(value) => formatNumberPersian(Number(value))} />
|
||||
<YAxis dataKey="label" type="category" width={110} tickLine={false} axisLine={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
|
||||
<Bar dataKey="value" radius={[8, 8, 8, 8]} fill={color} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendLineChart({ data, color = "var(--color-value)" }: { data: AnalyticsTrendPointSchema[]; color?: string }) {
|
||||
if (!data.length) return <EmptyChart />;
|
||||
return (
|
||||
<ChartContainer config={{ value: { label: "مقدار", color } }} className="h-[260px] w-full">
|
||||
<LineChart data={data} margin={{ left: 8, right: 8 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" tickLine={false} axisLine={false} minTickGap={28} />
|
||||
<YAxis tickFormatter={(value) => formatNumberPersian(Number(value))} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Line type="monotone" dataKey="value" stroke={color} strokeWidth={3} dot={{ r: 3 }} />
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBarChart({ data }: { data: AdminDashboardAnalyticsSchema["events"]["registration_status"] }) {
|
||||
if (!data.length) return <EmptyChart />;
|
||||
return (
|
||||
<ChartContainer config={{ value: { label: "تعداد", color: "hsl(var(--primary))" } }} className="h-[260px] w-full">
|
||||
<BarChart data={data} margin={{ left: 8, right: 8 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" tickLine={false} axisLine={false} />
|
||||
<YAxis tickFormatter={(value) => formatNumberPersian(Number(value))} />
|
||||
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
|
||||
<Bar dataKey="value" radius={[8, 8, 0, 0]}>
|
||||
{data.map((_, index) => (
|
||||
<Cell key={index} fill={chartColors[index % chartColors.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function BlogScatterChart({ data }: { data: AnalyticsPostPopularitySchema[] }) {
|
||||
if (!data.length) return <EmptyChart />;
|
||||
return (
|
||||
<ChartContainer
|
||||
config={{
|
||||
saves: { label: "ذخیرهها", color: "hsl(var(--primary))" },
|
||||
likes: { label: "لایکها", color: "hsl(var(--chart-2, 173 58% 39%))" },
|
||||
}}
|
||||
className="h-[320px] w-full"
|
||||
>
|
||||
<ScatterChart margin={{ left: 8, right: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="likes"
|
||||
name="لایک"
|
||||
tickFormatter={(value) => formatNumberPersian(Number(value))}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="saves"
|
||||
name="ذخیره"
|
||||
tickFormatter={(value) => formatNumberPersian(Number(value))}
|
||||
/>
|
||||
<ZAxis type="number" dataKey="comments" range={[60, 360]} />
|
||||
<ChartTooltip
|
||||
cursor={{ strokeDasharray: "3 3" }}
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const item = payload[0].payload as AnalyticsPostPopularitySchema;
|
||||
return (
|
||||
<div className="min-w-48 rounded-lg border bg-background p-3 text-xs shadow-xl" dir="rtl">
|
||||
<p className="mb-2 font-semibold">{item.title}</p>
|
||||
<div className="space-y-1 text-muted-foreground">
|
||||
<p>لایک: {formatNumberPersian(item.likes)}</p>
|
||||
<p>ذخیره: {formatNumberPersian(item.saves)}</p>
|
||||
<p>کامنت: {formatNumberPersian(item.comments)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Scatter data={data} fill="var(--color-saves)" />
|
||||
</ScatterChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function TopList({
|
||||
title,
|
||||
items,
|
||||
renderMeta,
|
||||
}: {
|
||||
title: string;
|
||||
items: Array<{ id: number; title: string }>;
|
||||
renderMeta: (item: { id: number; title: string }) => React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{items.length ? (
|
||||
items.map((item, index) => (
|
||||
<div key={item.id} className="flex items-center justify-between gap-3 rounded-xl border bg-background/70 p-3">
|
||||
<div className="min-w-0 text-right">
|
||||
<p className="truncate font-medium">{item.title}</p>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{renderMeta(item)}</div>
|
||||
</div>
|
||||
<Badge variant="secondary">{toPersianDigits(index + 1)}</Badge>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">دادهای وجود ندارد.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityTrendChart({ data }: { data: AdminDashboardAnalyticsSchema["blog"]["activity_trend"] }) {
|
||||
if (!data.length) return <EmptyChart />;
|
||||
return (
|
||||
<ChartContainer
|
||||
config={{
|
||||
likes: { label: "لایک", color: "hsl(var(--primary))" },
|
||||
saves: { label: "ذخیره", color: "hsl(var(--chart-2, 173 58% 39%))" },
|
||||
comments: { label: "کامنت", color: "hsl(var(--chart-5, 27 87% 67%))" },
|
||||
}}
|
||||
className="h-[260px] w-full"
|
||||
>
|
||||
<LineChart data={data} margin={{ left: 8, right: 8 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tickLine={false} axisLine={false} minTickGap={28} />
|
||||
<YAxis tickFormatter={(value) => formatNumberPersian(Number(value))} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Line type="monotone" dataKey="likes" stroke="var(--color-likes)" strokeWidth={3} dot={false} />
|
||||
<Line type="monotone" dataKey="saves" stroke="var(--color-saves)" strokeWidth={3} dot={false} />
|
||||
<Line type="monotone" dataKey="comments" stroke="var(--color-comments)" strokeWidth={3} dot={false} />
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [filters, setFilters] = React.useState<DashboardFilters>({
|
||||
date_from: "",
|
||||
date_to: "",
|
||||
event_id: "all",
|
||||
granularity: "auto",
|
||||
});
|
||||
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: ["admin", "dashboard", "events"],
|
||||
queryFn: () => api.getEvents({ limit: 100 }),
|
||||
});
|
||||
|
||||
const dashboardQuery = useQuery({
|
||||
queryKey: ["admin", "dashboard", filters],
|
||||
queryFn: () =>
|
||||
api.getAdminDashboard({
|
||||
date_from: filters.date_from || undefined,
|
||||
date_to: filters.date_to || undefined,
|
||||
event_id: filters.event_id === "all" ? undefined : Number(filters.event_id),
|
||||
granularity: filters.granularity,
|
||||
}),
|
||||
});
|
||||
|
||||
const dashboard = dashboardQuery.data;
|
||||
const selectedEvent = React.useMemo(() => {
|
||||
if (filters.event_id === "all") return null;
|
||||
return (eventsQuery.data ?? []).find((event) => String(event.id) === filters.event_id) ?? null;
|
||||
}, [eventsQuery.data, filters.event_id]);
|
||||
|
||||
const setFilter = <K extends keyof DashboardFilters>(key: K, value: DashboardFilters[K]) => {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilters({ date_from: "", date_to: "", event_id: "all", granularity: "auto" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6" dir="rtl">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black tracking-tight">داشبورد دستاوردها</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
نمای کلی از رشد کاربران، تنوع رویدادها، درآمد و تعاملات بلاگ انجمن
|
||||
</p>
|
||||
</div>
|
||||
{selectedEvent ? <Badge className="w-fit">فیلتر رویداد: {selectedEvent.title}</Badge> : null}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5 text-primary" />
|
||||
فیلتر گزارش
|
||||
</CardTitle>
|
||||
<CardDescription>بازه زمانی روی هر بخش با تاریخ مناسب همان بخش اعمال میشود.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1.2fr_1fr_auto]">
|
||||
<div className="space-y-2">
|
||||
<Label>از تاریخ</Label>
|
||||
<Input
|
||||
type="date"
|
||||
dir="ltr"
|
||||
value={filters.date_from}
|
||||
onChange={(event) => setFilter("date_from", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>تا تاریخ</Label>
|
||||
<Input
|
||||
type="date"
|
||||
dir="ltr"
|
||||
value={filters.date_to}
|
||||
onChange={(event) => setFilter("date_to", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>رویداد</Label>
|
||||
<Select value={filters.event_id} onValueChange={(value) => setFilter("event_id", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">همه رویدادها</SelectItem>
|
||||
{(eventsQuery.data ?? []).map((event: EventListItemSchema) => (
|
||||
<SelectItem key={event.id} value={String(event.id)}>
|
||||
{event.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>دقت زمانی</Label>
|
||||
<Select value={filters.granularity} onValueChange={(value) => setFilter("granularity", value as DashboardFilters["granularity"])}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">خودکار</SelectItem>
|
||||
<SelectItem value="day">روزانه</SelectItem>
|
||||
<SelectItem value="week">هفتگی</SelectItem>
|
||||
<SelectItem value="month">ماهانه</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button variant="outline" className="w-full" onClick={resetFilters}>
|
||||
پاککردن
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{dashboardQuery.isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-sm text-muted-foreground">در حال بارگذاری دادههای داشبورد...</CardContent>
|
||||
</Card>
|
||||
) : dashboardQuery.isError ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-sm text-destructive">
|
||||
{resolveErrorMessage(dashboardQuery.error)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : dashboard ? (
|
||||
<DashboardContent dashboard={dashboard} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardContent({ dashboard }: { dashboard: AdminDashboardAnalyticsSchema }) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
title="کاربران ثبتنامشده"
|
||||
value={formatNumberPersian(dashboard.summary.total_users)}
|
||||
description={`تایید موبایل: ${formatNumberPersian(dashboard.summary.verified_users)}`}
|
||||
icon={UsersRound}
|
||||
/>
|
||||
<StatCard
|
||||
title="ثبتنام رویدادها"
|
||||
value={formatNumberPersian(dashboard.summary.total_registrations)}
|
||||
description={`${formatNumberPersian(dashboard.achievements.distinct_participants)} شرکتکننده یکتا`}
|
||||
icon={CalendarDays}
|
||||
/>
|
||||
<StatCard
|
||||
title="درآمد پرداختشده"
|
||||
value={formatToman(dashboard.summary.total_revenue)}
|
||||
description={`تخفیف دادهشده: ${formatToman(dashboard.summary.total_discount)}`}
|
||||
icon={WalletCards}
|
||||
/>
|
||||
<StatCard
|
||||
title="تعامل بلاگ"
|
||||
value={formatNumberPersian(dashboard.achievements.community_engagement)}
|
||||
description={`${formatNumberPersian(dashboard.summary.published_posts)} نوشته منتشرشده`}
|
||||
icon={Activity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
title="ساعت-نفر آموزشی"
|
||||
value={formatNumberPersian(dashboard.achievements.learning_hours)}
|
||||
description="تقریبی، بر اساس مدت رویداد و شرکتکننده تاییدشده"
|
||||
icon={BookOpen}
|
||||
/>
|
||||
<StatCard
|
||||
title="لایکهای بلاگ"
|
||||
value={formatNumberPersian(dashboard.summary.total_likes)}
|
||||
description="روی نوشتههای منتشرشده"
|
||||
icon={Heart}
|
||||
/>
|
||||
<StatCard
|
||||
title="ذخیرههای بلاگ"
|
||||
value={formatNumberPersian(dashboard.summary.total_saves)}
|
||||
description="شاخص علاقه به مطالعه دوباره"
|
||||
icon={Save}
|
||||
/>
|
||||
<StatCard
|
||||
title="کل پایه پرداختها"
|
||||
value={formatToman(dashboard.revenue.total_base)}
|
||||
description="قبل از اعمال تخفیفهای موفق"
|
||||
icon={Landmark}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ترکیب کاربران بر اساس رشته</CardTitle>
|
||||
<CardDescription>کاربران ثبتنامشده در بازه انتخابی</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VerticalBarChart data={dashboard.users.by_major} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ترکیب کاربران بر اساس دانشگاه</CardTitle>
|
||||
<CardDescription>برای سنجش گستره جذب انجمن</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VerticalBarChart data={dashboard.users.by_university} color="hsl(var(--chart-2, 173 58% 39%))" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<LineChartIcon className="h-5 w-5 text-primary" />
|
||||
روند درآمد موفق
|
||||
</CardTitle>
|
||||
<CardDescription>فقط پرداختهای تاییدشده درگاه</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TrendLineChart data={dashboard.revenue.trend} color="hsl(var(--chart-2, 173 58% 39%))" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>وضعیت ثبتنامها</CardTitle>
|
||||
<CardDescription>همه وضعیتها در بازه/رویداد انتخابی</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatusBarChart data={dashboard.events.registration_status} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>تنوع رشته در رویدادها</CardTitle>
|
||||
<CardDescription>ثبتنامهای تاییدشده و حاضرشده</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VerticalBarChart data={dashboard.events.by_major} color="hsl(var(--primary))" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>تنوع دانشگاه در رویدادها</CardTitle>
|
||||
<CardDescription>قابل فیلتر برای هر رویداد</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VerticalBarChart data={dashboard.events.by_university} color="hsl(var(--chart-2, 173 58% 39%))" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>محبوبیت نوشتهها</CardTitle>
|
||||
<CardDescription>نمودار نقطهای لایک در برابر ذخیره؛ اندازه نقطه بر اساس کامنت</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BlogScatterChart data={dashboard.blog.post_popularity} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>روند تعاملات بلاگ</CardTitle>
|
||||
<CardDescription>لایک، ذخیره و کامنت در زمان</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActivityTrendChart data={dashboard.blog.activity_trend} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<TopList
|
||||
title="رویدادهای برتر"
|
||||
items={dashboard.events.top_events}
|
||||
renderMeta={(item) => {
|
||||
const event = dashboard.events.top_events.find((candidate) => candidate.id === item.id);
|
||||
if (!event) return null;
|
||||
return (
|
||||
<span>
|
||||
{formatNumberPersian(event.attendees)} نفر
|
||||
{event.fill_rate != null ? ` · ${formatNumberPersian(event.fill_rate)}٪ ظرفیت` : ""}
|
||||
{event.revenue ? ` · ${formatToman(event.revenue)}` : ""}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<TopList
|
||||
title="نوشتههای برتر"
|
||||
items={dashboard.blog.top_posts}
|
||||
renderMeta={(item) => {
|
||||
const post = dashboard.blog.top_posts.find((candidate) => candidate.id === item.id);
|
||||
if (!post) return null;
|
||||
return (
|
||||
<span>
|
||||
{formatNumberPersian(post.likes)} لایک · {formatNumberPersian(post.saves)} ذخیره ·{" "}
|
||||
{formatNumberPersian(post.comments)} کامنت
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>موضوعات فعال بلاگ</CardTitle>
|
||||
<CardDescription>دستهبندیها و تگهای دارای نوشته منتشرشده</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-1">
|
||||
<div>
|
||||
<p className="mb-3 text-sm font-semibold">دستهبندیها</p>
|
||||
<VerticalBarChart data={dashboard.blog.by_category} color="hsl(var(--chart-3, 197 37% 24%))" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-3 text-sm font-semibold">تگها</p>
|
||||
<VerticalBarChart data={dashboard.blog.by_tag} color="hsl(var(--chart-5, 27 87% 67%))" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-primary" />
|
||||
روند رشد کاربران و ثبتنام رویدادها
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 xl:grid-cols-2">
|
||||
<TrendLineChart data={dashboard.users.signup_trend} color="hsl(var(--primary))" />
|
||||
<TrendLineChart data={dashboard.events.registration_trend} color="hsl(var(--chart-2, 173 58% 39%))" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user