925 lines
36 KiB
TypeScript
925 lines
36 KiB
TypeScript
"use client";
|
||
|
||
import * as React from "react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import DateObject from "react-date-object";
|
||
import persian from "react-date-object/calendars/persian";
|
||
import persian_fa from "react-date-object/locales/persian_fa";
|
||
import DatePicker from "react-multi-date-picker";
|
||
import {
|
||
Activity,
|
||
BarChart3,
|
||
BookOpen,
|
||
CalendarDays,
|
||
GraduationCap,
|
||
Heart,
|
||
LibraryBig,
|
||
LineChart as LineChartIcon,
|
||
MessageCircle,
|
||
Save,
|
||
Tags,
|
||
UsersRound,
|
||
WalletCards,
|
||
} from "lucide-react";
|
||
import {
|
||
Bar,
|
||
BarChart,
|
||
CartesianGrid,
|
||
Cell,
|
||
Line,
|
||
LineChart,
|
||
Scatter,
|
||
ScatterChart,
|
||
XAxis,
|
||
YAxis,
|
||
ZAxis,
|
||
} from "recharts";
|
||
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
|
||
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 { Label } from "@/components/ui/label";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { api } from "@/lib/api";
|
||
import type {
|
||
AnalyticsPointGroupSchema,
|
||
AnalyticsPointSchema,
|
||
AnalyticsPostPopularitySchema,
|
||
AnalyticsTrendPointSchema,
|
||
BlogAnalyticsSchema,
|
||
EventAnalyticsSchema,
|
||
UserAnalyticsSchema,
|
||
} from "@/lib/types";
|
||
import {
|
||
cn,
|
||
formatJalaliDate,
|
||
formatNumberPersian,
|
||
formatToman,
|
||
resolveErrorMessage,
|
||
toPersianDigits,
|
||
} from "@/lib/utils";
|
||
|
||
const PALETTE = {
|
||
teal: "#2dd4bf",
|
||
cyan: "#38bdf8",
|
||
amber: "#fbbf24",
|
||
rose: "#fb7185",
|
||
violet: "#a78bfa",
|
||
emerald: "#34d399",
|
||
slate: "#94a3b8",
|
||
};
|
||
|
||
const STATUS_COLORS = [PALETTE.teal, PALETTE.cyan, PALETTE.amber, PALETTE.rose, PALETTE.violet, PALETTE.emerald];
|
||
|
||
type DateRangeState = {
|
||
from: string;
|
||
to: string;
|
||
};
|
||
|
||
type SectionState = DateRangeState & {
|
||
eventId?: string | null;
|
||
};
|
||
|
||
function toApiDate(date: DateObject | null) {
|
||
if (!date) return "";
|
||
const gregorian = date.toDate();
|
||
const year = gregorian.getFullYear();
|
||
const month = String(gregorian.getMonth() + 1).padStart(2, "0");
|
||
const day = String(gregorian.getDate()).padStart(2, "0");
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
|
||
function fromApiDate(value?: string | null) {
|
||
if (!value) return null;
|
||
const [year, month, day] = value.split("-").map(Number);
|
||
if (!year || !month || !day) return null;
|
||
return new DateObject({
|
||
date: new Date(year, month - 1, day),
|
||
calendar: persian,
|
||
locale: persian_fa,
|
||
});
|
||
}
|
||
|
||
function formatJalaliTick(value?: string | number) {
|
||
if (!value) return "";
|
||
const raw = String(value);
|
||
const date = new Date(raw.length === 10 ? `${raw}T00:00:00` : raw);
|
||
if (Number.isNaN(date.getTime())) return toPersianDigits(raw);
|
||
const locale = Intl.DateTimeFormat.supportedLocalesOf(["fa-IR-u-ca-persian"]).length
|
||
? "fa-IR-u-ca-persian"
|
||
: "fa-IR";
|
||
return new Intl.DateTimeFormat(locale, { month: "short", day: "numeric" }).format(date);
|
||
}
|
||
|
||
function truncateLabel(value: string, max = 18) {
|
||
const normalized = toPersianDigits(value);
|
||
return normalized.length > max ? `${normalized.slice(0, max - 1)}…` : normalized;
|
||
}
|
||
|
||
function chartHeight(count: number, min = 280) {
|
||
return Math.max(min, count * 38 + 90);
|
||
}
|
||
|
||
function axisWidth(items: AnalyticsPointSchema[]) {
|
||
const maxLength = Math.max(...items.map((item) => String(item.label).length), 10);
|
||
return Math.min(190, Math.max(90, maxLength * 7));
|
||
}
|
||
|
||
function dataWithOtherNotice(group: AnalyticsPointGroupSchema) {
|
||
return group.top_items;
|
||
}
|
||
|
||
function StatCard({
|
||
title,
|
||
value,
|
||
description,
|
||
icon: Icon,
|
||
tone = "teal",
|
||
}: {
|
||
title: string;
|
||
value: string;
|
||
description: string;
|
||
icon: React.ComponentType<{ className?: string }>;
|
||
tone?: keyof typeof PALETTE;
|
||
}) {
|
||
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 leading-tight tracking-tight">{value}</p>
|
||
<p className="text-xs leading-6 text-muted-foreground">{description}</p>
|
||
</div>
|
||
<div className="rounded-2xl p-3" style={{ backgroundColor: `${PALETTE[tone]}22`, color: PALETTE[tone] }}>
|
||
<Icon className="h-6 w-6" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function SectionError({ error }: { error: unknown }) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-sm text-destructive">{resolveErrorMessage(error)}</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function SectionLoading() {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-sm text-muted-foreground">در حال بارگذاری دادههای داشبورد...</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 DateRangeFilter({
|
||
value,
|
||
onChange,
|
||
onReset,
|
||
}: {
|
||
value: DateRangeState;
|
||
onChange: (next: DateRangeState) => void;
|
||
onReset: () => void;
|
||
}) {
|
||
return (
|
||
<div className="grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
||
<div className="space-y-2">
|
||
<Label>از تاریخ</Label>
|
||
<DatePicker
|
||
value={fromApiDate(value.from)}
|
||
onChange={(next) => onChange({ ...value, from: toApiDate(next instanceof DateObject ? next : null) })}
|
||
calendar={persian}
|
||
locale={persian_fa}
|
||
calendarPosition="bottom-right"
|
||
inputClass="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-right ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||
placeholder="تاریخ شروع"
|
||
containerClassName="w-full"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>تا تاریخ</Label>
|
||
<DatePicker
|
||
value={fromApiDate(value.to)}
|
||
onChange={(next) => onChange({ ...value, to: toApiDate(next instanceof DateObject ? next : null) })}
|
||
calendar={persian}
|
||
locale={persian_fa}
|
||
calendarPosition="bottom-right"
|
||
inputClass="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-right ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||
placeholder="تاریخ پایان"
|
||
containerClassName="w-full"
|
||
/>
|
||
</div>
|
||
<div className="flex items-end">
|
||
<Button variant="outline" className="w-full md:w-auto" onClick={onReset}>
|
||
پاککردن
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FilterCard({
|
||
title,
|
||
description,
|
||
children,
|
||
}: {
|
||
title: string;
|
||
description: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<BarChart3 className="h-5 w-5 text-primary" />
|
||
{title}
|
||
</CardTitle>
|
||
<CardDescription>{description}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>{children}</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function ChartViewport({ children, minWidth = 560 }: { children: React.ReactNode; minWidth?: number }) {
|
||
return (
|
||
<div className="overflow-x-auto overflow-y-hidden pb-2" dir="ltr">
|
||
<div style={{ minWidth }}>{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CustomValueTooltip({
|
||
active,
|
||
payload,
|
||
unit,
|
||
formatter,
|
||
}: {
|
||
active?: boolean;
|
||
payload?: Array<{ payload?: AnalyticsPointSchema; value?: number }>;
|
||
unit?: string;
|
||
formatter?: (value: number) => string;
|
||
}) {
|
||
if (!active || !payload?.length || !payload[0].payload) return null;
|
||
const item = payload[0].payload;
|
||
return (
|
||
<div className="rounded-lg border bg-background px-3 py-2 text-xs shadow-xl" dir="rtl">
|
||
<p className="mb-1 max-w-64 font-medium">{toPersianDigits(item.label)}</p>
|
||
<p className="text-muted-foreground">
|
||
{formatter ? formatter(Number(item.value)) : formatNumberPersian(item.value)}
|
||
{unit ? ` ${unit}` : ""}
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function HighCardinalityNotice({ group }: { group: AnalyticsPointGroupSchema }) {
|
||
if (group.total_count <= group.top_items.length) return null;
|
||
return (
|
||
<p className="mt-2 text-xs text-muted-foreground">
|
||
نمایش {formatNumberPersian(group.top_items.length)} مورد برتر از {formatNumberPersian(group.total_count)} مورد؛{" "}
|
||
{formatNumberPersian(group.other_count)} مورد دیگر در نمودار فشرده نشدهاند.
|
||
</p>
|
||
);
|
||
}
|
||
|
||
function ValuesTable({
|
||
data,
|
||
unit,
|
||
formatter,
|
||
}: {
|
||
data: AnalyticsPointSchema[];
|
||
unit?: string;
|
||
formatter?: (value: number) => string;
|
||
}) {
|
||
if (!data.length) return null;
|
||
return (
|
||
<div className="mt-4 max-h-56 overflow-auto rounded-xl border">
|
||
<table className="w-full text-sm">
|
||
<tbody>
|
||
{data.map((item, index) => (
|
||
<tr key={`${item.label}-${index}`} className="border-b last:border-b-0">
|
||
<td className="w-10 px-3 py-2 text-muted-foreground">{toPersianDigits(index + 1)}</td>
|
||
<td className="px-3 py-2 text-right">{toPersianDigits(item.label)}</td>
|
||
<td className="px-3 py-2 text-left font-medium">
|
||
{formatter ? formatter(Number(item.value)) : formatNumberPersian(item.value)}
|
||
{unit ? ` ${unit}` : ""}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function HorizontalBarCard({
|
||
title,
|
||
description,
|
||
group,
|
||
color = PALETTE.teal,
|
||
unit,
|
||
valueFormatter,
|
||
}: {
|
||
title: string;
|
||
description: string;
|
||
group: AnalyticsPointGroupSchema;
|
||
color?: string;
|
||
unit?: string;
|
||
valueFormatter?: (value: number) => string;
|
||
}) {
|
||
const data = dataWithOtherNotice(group);
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>{title}</CardTitle>
|
||
<CardDescription>{description}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{!data.length ? (
|
||
<EmptyChart />
|
||
) : (
|
||
<>
|
||
<ChartViewport>
|
||
<ChartContainer
|
||
config={{ value: { label: "مقدار", color } }}
|
||
className="w-full"
|
||
style={{ height: chartHeight(data.length) }}
|
||
>
|
||
<BarChart data={data} layout="vertical" margin={{ top: 12, right: axisWidth(data) + 10, bottom: 24, left: 20 }}>
|
||
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
||
<XAxis
|
||
type="number"
|
||
reversed
|
||
tickLine={false}
|
||
axisLine={false}
|
||
tickMargin={8}
|
||
tickFormatter={(value) => valueFormatter ? valueFormatter(Number(value)) : formatNumberPersian(Number(value))}
|
||
/>
|
||
<YAxis
|
||
dataKey="label"
|
||
type="category"
|
||
orientation="right"
|
||
width={axisWidth(data)}
|
||
tickLine={false}
|
||
axisLine={false}
|
||
tickMargin={10}
|
||
tickFormatter={(value) => truncateLabel(String(value))}
|
||
/>
|
||
<ChartTooltip content={<CustomValueTooltip unit={unit} formatter={valueFormatter} />} />
|
||
<Bar dataKey="value" radius={[8, 8, 8, 8]} fill="var(--color-value)" />
|
||
</BarChart>
|
||
</ChartContainer>
|
||
</ChartViewport>
|
||
<HighCardinalityNotice group={group} />
|
||
<ValuesTable data={data} unit={unit} formatter={valueFormatter} />
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function TrendLineCard({
|
||
title,
|
||
description,
|
||
data,
|
||
color = PALETTE.teal,
|
||
valueFormatter = formatNumberPersian,
|
||
}: {
|
||
title: string;
|
||
description: string;
|
||
data: AnalyticsTrendPointSchema[];
|
||
color?: string;
|
||
valueFormatter?: (value: number) => string;
|
||
}) {
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<LineChartIcon className="h-5 w-5 text-primary" />
|
||
{title}
|
||
</CardTitle>
|
||
<CardDescription>{description}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{!data.length ? (
|
||
<EmptyChart />
|
||
) : (
|
||
<ChartViewport>
|
||
<ChartContainer config={{ value: { label: "مقدار", color } }} className="h-[300px] w-full">
|
||
<LineChart data={data} margin={{ top: 16, right: 76, bottom: 32, left: 18 }}>
|
||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||
<XAxis
|
||
dataKey="date"
|
||
tickLine={false}
|
||
axisLine={false}
|
||
minTickGap={34}
|
||
tickMargin={10}
|
||
tickFormatter={formatJalaliTick}
|
||
/>
|
||
<YAxis
|
||
orientation="right"
|
||
width={76}
|
||
tickLine={false}
|
||
axisLine={false}
|
||
tickMargin={10}
|
||
tickFormatter={(value) => valueFormatter(Number(value))}
|
||
/>
|
||
<ChartTooltip
|
||
content={({ active, payload }) => {
|
||
if (!active || !payload?.length) return null;
|
||
const item = payload[0].payload as AnalyticsTrendPointSchema;
|
||
return (
|
||
<div className="rounded-lg border bg-background px-3 py-2 text-xs shadow-xl" dir="rtl">
|
||
<p className="mb-1 font-medium">{formatJalaliDate(item.date)}</p>
|
||
<p className="text-muted-foreground">{valueFormatter(Number(item.value))}</p>
|
||
</div>
|
||
);
|
||
}}
|
||
/>
|
||
<Line type="monotone" dataKey="value" stroke="var(--color-value)" strokeWidth={3} dot={{ r: 3 }} />
|
||
</LineChart>
|
||
</ChartContainer>
|
||
</ChartViewport>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function StatusChartCard({
|
||
title,
|
||
description,
|
||
data,
|
||
}: {
|
||
title: string;
|
||
description: string;
|
||
data: Array<{ status: string; label: string; value: number }>;
|
||
}) {
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>{title}</CardTitle>
|
||
<CardDescription>{description}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{!data.length ? (
|
||
<EmptyChart />
|
||
) : (
|
||
<>
|
||
<ChartViewport minWidth={460}>
|
||
<ChartContainer config={{ value: { label: "تعداد", color: PALETTE.teal } }} className="h-[300px] w-full">
|
||
<BarChart data={data} layout="vertical" margin={{ top: 14, right: 118, bottom: 28, left: 18 }}>
|
||
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
||
<XAxis
|
||
type="number"
|
||
reversed
|
||
tickLine={false}
|
||
axisLine={false}
|
||
tickMargin={8}
|
||
tickFormatter={(value) => formatNumberPersian(Number(value))}
|
||
/>
|
||
<YAxis
|
||
dataKey="label"
|
||
type="category"
|
||
orientation="right"
|
||
width={108}
|
||
tickLine={false}
|
||
axisLine={false}
|
||
tickMargin={10}
|
||
tickFormatter={(value) => truncateLabel(String(value), 14)}
|
||
/>
|
||
<ChartTooltip content={<CustomValueTooltip />} />
|
||
<Bar dataKey="value" radius={[8, 8, 8, 8]}>
|
||
{data.map((_, index) => (
|
||
<Cell key={index} fill={STATUS_COLORS[index % STATUS_COLORS.length]} />
|
||
))}
|
||
</Bar>
|
||
</BarChart>
|
||
</ChartContainer>
|
||
</ChartViewport>
|
||
<ValuesTable data={data.map((item) => ({ label: item.label, value: item.value }))} />
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function ActivityTrendCard({ data }: { data: BlogAnalyticsSchema["activity_trend"] }) {
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>روند تعاملات بلاگ</CardTitle>
|
||
<CardDescription>لایک، ذخیره و کامنت در بازه انتخابی</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{!data.length ? (
|
||
<EmptyChart />
|
||
) : (
|
||
<ChartViewport>
|
||
<ChartContainer
|
||
config={{
|
||
likes: { label: "لایک", color: PALETTE.rose },
|
||
saves: { label: "ذخیره", color: PALETTE.cyan },
|
||
comments: { label: "کامنت", color: PALETTE.amber },
|
||
}}
|
||
className="h-[320px] w-full"
|
||
>
|
||
<LineChart data={data} margin={{ top: 16, right: 64, bottom: 32, left: 18 }}>
|
||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||
<XAxis dataKey="date" tickLine={false} axisLine={false} tickFormatter={formatJalaliTick} minTickGap={34} tickMargin={10} />
|
||
<YAxis
|
||
orientation="right"
|
||
width={64}
|
||
tickLine={false}
|
||
axisLine={false}
|
||
tickMargin={10}
|
||
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>
|
||
</ChartViewport>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function BlogScatterCard({ group }: { group: BlogAnalyticsSchema["post_popularity"] }) {
|
||
const data = group.top_items;
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>محبوبیت نوشتهها</CardTitle>
|
||
<CardDescription>لایک در برابر ذخیره؛ اندازه نقطه بر اساس تعداد کامنت</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{!data.length ? (
|
||
<EmptyChart />
|
||
) : (
|
||
<>
|
||
<ChartViewport minWidth={620}>
|
||
<ChartContainer
|
||
config={{
|
||
saves: { label: "ذخیره", color: PALETTE.cyan },
|
||
likes: { label: "لایک", color: PALETTE.rose },
|
||
}}
|
||
className="h-[340px] w-full"
|
||
>
|
||
<ScatterChart margin={{ top: 18, right: 18, bottom: 36, left: 30 }}>
|
||
<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={[70, 380]} />
|
||
<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-52 rounded-lg border bg-background p-3 text-xs shadow-xl" dir="rtl">
|
||
<p className="mb-2 max-w-72 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>
|
||
</ChartViewport>
|
||
{group.total_count > data.length ? (
|
||
<p className="mt-2 text-xs text-muted-foreground">
|
||
نمایش {formatNumberPersian(data.length)} نوشته برتر از {formatNumberPersian(group.total_count)} نوشته دارای تعامل.
|
||
</p>
|
||
) : null}
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function TopEventsCard({ group }: { group: EventAnalyticsSchema["top_events"] }) {
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>رویدادهای برتر</CardTitle>
|
||
<CardDescription>بر اساس شرکتکننده تاییدشده، درآمد و زمان برگزاری</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{group.top_items.length ? (
|
||
group.top_items.map((event, index) => (
|
||
<div key={event.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">{event.title}</p>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
{formatNumberPersian(event.attendees)} نفر
|
||
{event.fill_rate != null ? ` · ${formatNumberPersian(event.fill_rate)}٪ ظرفیت` : ""}
|
||
{event.revenue ? ` · ${formatToman(event.revenue)}` : ""}
|
||
</p>
|
||
</div>
|
||
<Badge variant="secondary">{toPersianDigits(index + 1)}</Badge>
|
||
</div>
|
||
))
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">دادهای وجود ندارد.</p>
|
||
)}
|
||
{group.total_count > group.top_items.length ? (
|
||
<p className="text-xs text-muted-foreground">
|
||
{formatNumberPersian(group.other_count)} رویداد دیگر در این لیست خلاصه نشدهاند.
|
||
</p>
|
||
) : null}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function TopPostsCard({ posts }: { posts: BlogAnalyticsSchema["top_posts"] }) {
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>نوشتههای برتر</CardTitle>
|
||
<CardDescription>بر اساس جمع لایک، ذخیره و کامنت</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{posts.length ? (
|
||
posts.map((post, index) => (
|
||
<div key={post.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">{post.title}</p>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
{formatNumberPersian(post.likes)} لایک · {formatNumberPersian(post.saves)} ذخیره ·{" "}
|
||
{formatNumberPersian(post.comments)} کامنت
|
||
</p>
|
||
</div>
|
||
<Badge variant="secondary">{toPersianDigits(index + 1)}</Badge>
|
||
</div>
|
||
))
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">دادهای وجود ندارد.</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function UsersSection() {
|
||
const [filters, setFilters] = React.useState<DateRangeState>({ from: "", to: "" });
|
||
const query = useQuery({
|
||
queryKey: ["admin", "analytics", "users", filters],
|
||
queryFn: () => api.getAdminUserAnalytics({ date_from: filters.from || undefined, date_to: filters.to || undefined }),
|
||
});
|
||
|
||
return (
|
||
<div className="space-y-6" dir="rtl">
|
||
<FilterCard title="فیلتر کاربران" description="این فیلتر فقط روی کاربران و تاریخ عضویت آنها اعمال میشود.">
|
||
<DateRangeFilter value={filters} onChange={setFilters} onReset={() => setFilters({ from: "", to: "" })} />
|
||
</FilterCard>
|
||
{query.isLoading ? <SectionLoading /> : null}
|
||
{query.isError ? <SectionError error={query.error} /> : null}
|
||
{query.data ? <UsersContent data={query.data} /> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function UsersContent({ data }: { data: UserAnalyticsSchema }) {
|
||
return (
|
||
<>
|
||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||
<StatCard title="کل کاربران" value={formatNumberPersian(data.summary.total_users)} description="ثبتنامشده در بازه انتخابی" icon={UsersRound} />
|
||
<StatCard title="کاربران فعال" value={formatNumberPersian(data.summary.verified_users)} description="دارای موبایل تاییدشده" icon={Activity} tone="emerald" />
|
||
<StatCard title="کاربران تاییدنشده" value={formatNumberPersian(data.summary.unverified_users)} description="نیازمند تایید موبایل" icon={UsersRound} tone="amber" />
|
||
<StatCard
|
||
title="تکمیل پروفایل"
|
||
value={`${formatNumberPersian(data.summary.profile_completion_rate)}٪`}
|
||
description="نام، موبایل، دانشگاه و رشته"
|
||
icon={GraduationCap}
|
||
tone="cyan"
|
||
/>
|
||
</div>
|
||
<div className="grid gap-4 xl:grid-cols-2">
|
||
<TrendLineCard title="روند عضویت کاربران" description="بر اساس تاریخ عضویت" data={data.signup_trend} color={PALETTE.cyan} />
|
||
<HorizontalBarCard title="کاربران بر اساس سال ورود" description="برای شناخت ترکیب ورودیها" group={data.by_year} color={PALETTE.violet} />
|
||
</div>
|
||
<div className="grid gap-4 xl:grid-cols-2">
|
||
<HorizontalBarCard title="کاربران بر اساس رشته" description="۱۲ رشته پرتکرار و جدول مقدار دقیق" group={data.by_major} color={PALETTE.teal} />
|
||
<HorizontalBarCard title="کاربران بر اساس دانشگاه" description="۱۲ دانشگاه پرتکرار و جدول مقدار دقیق" group={data.by_university} color={PALETTE.cyan} />
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function EventsSection() {
|
||
const [filters, setFilters] = React.useState<SectionState>({ from: "", to: "", eventId: null });
|
||
const query = useQuery({
|
||
queryKey: ["admin", "analytics", "events", filters],
|
||
queryFn: () =>
|
||
api.getAdminEventAnalytics({
|
||
date_from: filters.from || undefined,
|
||
date_to: filters.to || undefined,
|
||
event_id: filters.eventId ? Number(filters.eventId) : undefined,
|
||
}),
|
||
});
|
||
|
||
const reset = () => setFilters({ from: "", to: "", eventId: null });
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<FilterCard title="فیلتر رویدادها" description="این فیلتر فقط روی آمار رویداد، ثبتنام، درآمد و تنوع شرکتکنندگان اعمال میشود.">
|
||
<div className="grid gap-3 xl:grid-cols-[1fr_1fr_1.2fr_auto]">
|
||
<div className="xl:col-span-2">
|
||
<DateRangeFilter
|
||
value={filters}
|
||
onChange={(next) => setFilters((current) => ({ ...current, ...next }))}
|
||
onReset={reset}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>رویداد</Label>
|
||
<AsyncSearchableCombobox
|
||
value={filters.eventId ?? null}
|
||
onChange={(eventId) => setFilters((current) => ({ ...current, eventId }))}
|
||
loadOptions={async ({ search, limit, offset }) => {
|
||
const data = await api.getAdminDashboardEventOptions({ search, limit, offset });
|
||
return {
|
||
count: data.count,
|
||
results: data.results.map((item) => ({
|
||
...item,
|
||
description: item.description ?? undefined,
|
||
})),
|
||
};
|
||
}}
|
||
placeholder="همه رویدادها"
|
||
searchPlaceholder="جستجوی رویداد..."
|
||
emptyText="رویدادی پیدا نشد."
|
||
/>
|
||
</div>
|
||
<div className="hidden items-end xl:flex">
|
||
<Button variant="outline" onClick={reset}>
|
||
پاککردن
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</FilterCard>
|
||
{query.isLoading ? <SectionLoading /> : null}
|
||
{query.isError ? <SectionError error={query.error} /> : null}
|
||
{query.data ? <EventsContent data={query.data} /> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EventsContent({ data }: { data: EventAnalyticsSchema }) {
|
||
return (
|
||
<>
|
||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||
<StatCard title="رویدادها" value={formatNumberPersian(data.summary.total_events)} description="بر اساس تاریخ برگزاری یا رویداد انتخابی" icon={CalendarDays} />
|
||
<StatCard title="ثبتنامها" value={formatNumberPersian(data.summary.total_registrations)} description={`${formatNumberPersian(data.summary.distinct_participants)} شرکتکننده یکتا`} icon={UsersRound} tone="cyan" />
|
||
<StatCard title="درآمد موفق" value={formatToman(data.summary.total_revenue)} description={`تخفیف: ${formatToman(data.summary.total_discount)}`} icon={WalletCards} tone="emerald" />
|
||
<StatCard title="ساعت-نفر آموزشی" value={formatNumberPersian(data.summary.learning_hours)} description="تقریبی، بر اساس مدت رویداد و شرکتکننده" icon={BookOpen} tone="violet" />
|
||
</div>
|
||
<div className="grid gap-4 xl:grid-cols-3">
|
||
<div className="xl:col-span-2">
|
||
<TrendLineCard title="روند درآمد موفق" description="فقط پرداختهای موفق درگاه" data={data.revenue_trend} color={PALETTE.emerald} valueFormatter={formatToman} />
|
||
</div>
|
||
<StatusChartCard title="وضعیت ثبتنامها" description="بر اساس وضعیت ثبتنام" data={data.registration_status} />
|
||
</div>
|
||
<div className="grid gap-4 xl:grid-cols-2">
|
||
<HorizontalBarCard title="تنوع رشته در رویدادها" description="ثبتنامهای تاییدشده و حاضرشده" group={data.attendee_by_major} color={PALETTE.teal} />
|
||
<HorizontalBarCard title="تنوع دانشگاه در رویدادها" description="ثبتنامهای تاییدشده و حاضرشده" group={data.attendee_by_university} color={PALETTE.cyan} />
|
||
</div>
|
||
<div className="grid gap-4 xl:grid-cols-3">
|
||
<div className="xl:col-span-2">
|
||
<TrendLineCard title="روند ثبتنام رویدادها" description="بر اساس تاریخ ثبتنام" data={data.registration_trend} color={PALETTE.amber} />
|
||
</div>
|
||
<StatusChartCard title="وضعیت پرداختها" description="پرداختها در بازه انتخابی" data={data.payment_status} />
|
||
</div>
|
||
<div className="grid gap-4 xl:grid-cols-2">
|
||
<HorizontalBarCard
|
||
title="درآمد بر اساس رویداد"
|
||
description="۱۲ رویداد پردرآمدتر"
|
||
group={data.revenue_by_event}
|
||
color={PALETTE.emerald}
|
||
valueFormatter={formatToman}
|
||
/>
|
||
<TopEventsCard group={data.top_events} />
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function BlogSection() {
|
||
const [filters, setFilters] = React.useState<DateRangeState>({ from: "", to: "" });
|
||
const query = useQuery({
|
||
queryKey: ["admin", "analytics", "blog", filters],
|
||
queryFn: () => api.getAdminBlogAnalytics({ date_from: filters.from || undefined, date_to: filters.to || undefined }),
|
||
});
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<FilterCard title="فیلتر بلاگ" description="این فیلتر فقط روی نوشتهها و تعاملات بلاگ اعمال میشود و به رویدادها وابسته نیست.">
|
||
<DateRangeFilter value={filters} onChange={setFilters} onReset={() => setFilters({ from: "", to: "" })} />
|
||
</FilterCard>
|
||
{query.isLoading ? <SectionLoading /> : null}
|
||
{query.isError ? <SectionError error={query.error} /> : null}
|
||
{query.data ? <BlogContent data={query.data} /> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BlogContent({ data }: { data: BlogAnalyticsSchema }) {
|
||
return (
|
||
<>
|
||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||
<StatCard title="نوشتههای منتشرشده" value={formatNumberPersian(data.summary.published_posts)} description="بر اساس تاریخ انتشار" icon={LibraryBig} />
|
||
<StatCard title="لایکها" value={formatNumberPersian(data.summary.total_likes)} description="روی نوشتههای منتشرشده" icon={Heart} tone="rose" />
|
||
<StatCard title="ذخیرهها" value={formatNumberPersian(data.summary.total_saves)} description="شاخص علاقه به مطالعه دوباره" icon={Save} tone="cyan" />
|
||
<StatCard title="کامنتها" value={formatNumberPersian(data.summary.total_comments)} description={`${formatNumberPersian(data.summary.community_engagement)} تعامل کل`} icon={MessageCircle} tone="amber" />
|
||
</div>
|
||
<div className="grid gap-4 xl:grid-cols-3">
|
||
<div className="xl:col-span-2">
|
||
<BlogScatterCard group={data.post_popularity} />
|
||
</div>
|
||
<ActivityTrendCard data={data.activity_trend} />
|
||
</div>
|
||
<div className="grid gap-4 xl:grid-cols-3">
|
||
<TopPostsCard posts={data.top_posts} />
|
||
<HorizontalBarCard title="دستهبندیهای فعال" description="۱۲ دستهبندی پرتکرار" group={data.by_category} color={PALETTE.violet} />
|
||
<HorizontalBarCard title="تگهای فعال" description="۱۲ تگ پرتکرار" group={data.by_tag} color={PALETTE.teal} />
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default function AdminDashboard() {
|
||
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>
|
||
</div>
|
||
|
||
<Tabs dir="rtl" defaultValue="users" className="space-y-6">
|
||
<TabsList className="grid h-auto w-full grid-cols-3 rounded-2xl p-1 sm:w-fit">
|
||
<TabsTrigger value="users" className="gap-2 rounded-xl py-2">
|
||
<UsersRound className="h-4 w-4" />
|
||
کاربران
|
||
</TabsTrigger>
|
||
<TabsTrigger value="events" className="gap-2 rounded-xl py-2">
|
||
<CalendarDays className="h-4 w-4" />
|
||
رویدادها
|
||
</TabsTrigger>
|
||
<TabsTrigger value="blog" className="gap-2 rounded-xl py-2">
|
||
<Tags className="h-4 w-4" />
|
||
بلاگ
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
<TabsContent value="users">
|
||
<UsersSection />
|
||
</TabsContent>
|
||
<TabsContent value="events">
|
||
<EventsSection />
|
||
</TabsContent>
|
||
<TabsContent value="blog">
|
||
<BlogSection />
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
);
|
||
}
|