Compare commits

...

2 Commits

Author SHA1 Message Date
6c3a7ed5f4 feat(admin): add analytics dashboard UI
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-14 09:52:41 +03:30
5c15727516 feat(admin): wire analytics dashboard route 2026-06-14 09:52:14 +03:30
6 changed files with 725 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
import AdminDashboard from "@/views/AdminDashboard";
export default function AdminDashboardPage() {
return <AdminDashboard />;
}

View File

@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminPage() {
redirect("/admin/users");
redirect("/admin/dashboard");
}

View File

@@ -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?: {

View File

@@ -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;

View 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>
</>
);
}

View File

@@ -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,