Compare commits
2 Commits
fc94ceb9f5
...
6c3a7ed5f4
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c3a7ed5f4 | |||
| 5c15727516 |
5
src/app/admin/dashboard/page.tsx
Normal file
5
src/app/admin/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminDashboard from "@/views/AdminDashboard";
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
return <AdminDashboard />;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminPage() {
|
||||
redirect("/admin/users");
|
||||
redirect("/admin/dashboard");
|
||||
}
|
||||
|
||||
@@ -416,6 +416,22 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getAdminDashboard(params?: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
event_id?: number;
|
||||
granularity?: 'auto' | 'day' | 'week' | 'month';
|
||||
}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.date_from) query.set('date_from', params.date_from);
|
||||
if (params?.date_to) query.set('date_to', params.date_to);
|
||||
if (params?.event_id != null) query.set('event_id', String(params.event_id));
|
||||
if (params?.granularity) query.set('granularity', params.granularity);
|
||||
return this.request<Types.AdminDashboardAnalyticsSchema>(
|
||||
`/api/analytics/admin/dashboard${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ============= Blog Endpoints =============
|
||||
|
||||
async getPosts(params?: {
|
||||
|
||||
102
src/lib/types.ts
102
src/lib/types.ts
@@ -753,6 +753,108 @@ export interface PaginatedResponse<T> {
|
||||
previous?: string;
|
||||
}
|
||||
|
||||
// Admin analytics
|
||||
export interface AnalyticsPointSchema {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsTrendPointSchema {
|
||||
date: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsRegistrationStatusSchema {
|
||||
status: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsTopEventSchema {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
attendees: number;
|
||||
capacity?: number | null;
|
||||
fill_rate?: number | null;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsPostPopularitySchema {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
likes: number;
|
||||
saves: number;
|
||||
comments: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsTopPostSchema extends AnalyticsPostPopularitySchema {
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface AdminDashboardAnalyticsSchema {
|
||||
filters: {
|
||||
date_from?: string | null;
|
||||
date_to?: string | null;
|
||||
event_id?: number | null;
|
||||
granularity: 'day' | 'week' | 'month';
|
||||
};
|
||||
summary: {
|
||||
total_users: number;
|
||||
verified_users: number;
|
||||
total_events: number;
|
||||
total_registrations: number;
|
||||
total_revenue: number;
|
||||
total_discount: number;
|
||||
published_posts: number;
|
||||
total_likes: number;
|
||||
total_saves: number;
|
||||
total_comments: number;
|
||||
};
|
||||
users: {
|
||||
signup_trend: AnalyticsTrendPointSchema[];
|
||||
by_major: AnalyticsPointSchema[];
|
||||
by_university: AnalyticsPointSchema[];
|
||||
by_year: AnalyticsPointSchema[];
|
||||
};
|
||||
events: {
|
||||
registration_status: AnalyticsRegistrationStatusSchema[];
|
||||
by_major: AnalyticsPointSchema[];
|
||||
by_university: AnalyticsPointSchema[];
|
||||
top_events: AnalyticsTopEventSchema[];
|
||||
registration_trend: AnalyticsTrendPointSchema[];
|
||||
};
|
||||
revenue: {
|
||||
trend: AnalyticsTrendPointSchema[];
|
||||
by_event: AnalyticsPointSchema[];
|
||||
payment_status: AnalyticsRegistrationStatusSchema[];
|
||||
total_paid: number;
|
||||
total_discount: number;
|
||||
total_base: number;
|
||||
};
|
||||
blog: {
|
||||
totals: {
|
||||
posts: number;
|
||||
likes: number;
|
||||
saves: number;
|
||||
comments: number;
|
||||
};
|
||||
post_popularity: AnalyticsPostPopularitySchema[];
|
||||
top_posts: AnalyticsTopPostSchema[];
|
||||
activity_trend: Array<{ date: string; likes: number; saves: number; comments: number }>;
|
||||
by_category: AnalyticsPointSchema[];
|
||||
by_tag: AnalyticsPointSchema[];
|
||||
};
|
||||
achievements: {
|
||||
distinct_participants: number;
|
||||
learning_hours: number;
|
||||
published_content: number;
|
||||
community_engagement: number;
|
||||
};
|
||||
}
|
||||
|
||||
// payment
|
||||
export interface CreatePaymentOut {
|
||||
start_pay_url: string;
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FileText,
|
||||
FolderTree,
|
||||
GraduationCap,
|
||||
LayoutDashboard,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
ShieldCheck,
|
||||
@@ -23,6 +24,13 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navGroups = [
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "داشبورد",
|
||||
items: [
|
||||
{ to: "/admin/dashboard", label: "داشبورد", icon: LayoutDashboard, visibility: "staff" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "users",
|
||||
label: "کاربران",
|
||||
@@ -59,6 +67,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const { user, isAuthenticated, loading } = useAuth();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
|
||||
dashboard: true,
|
||||
users: true,
|
||||
events: true,
|
||||
blog: true,
|
||||
|
||||
Reference in New Issue
Block a user