Files
guilan-ace-frontend/src/views/AdminDashboard.tsx

925 lines
36 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 * 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>
);
}