feat(admin-dashboard): sync tabs and filters with URL

This commit is contained in:
2026-06-15 17:32:03 +03:30
parent e3ddb733ee
commit 6ba8f6ec8b

View File

@@ -1,6 +1,7 @@
"use client";
import * as React from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import DateObject from "react-date-object";
import persian from "react-date-object/calendars/persian";
@@ -82,6 +83,26 @@ type SectionState = DateRangeState & {
eventId?: string | null;
};
type DashboardTab = "users" | "events" | "blog";
const DASHBOARD_TABS: DashboardTab[] = ["users", "events", "blog"];
function parseTab(value: string | null): DashboardTab {
return DASHBOARD_TABS.includes(value as DashboardTab) ? (value as DashboardTab) : "users";
}
function readDateRange(params: Pick<URLSearchParams, "get">, prefix: "users" | "events" | "blog"): DateRangeState {
return {
from: params.get(`${prefix}_from`) || "",
to: params.get(`${prefix}_to`) || "",
};
}
function setParam(params: URLSearchParams, key: string, value?: string | null) {
if (value) params.set(key, value);
else params.delete(key);
}
function toApiDate(date: DateObject | null) {
if (!date) return "";
const gregorian = date.toDate();
@@ -699,8 +720,13 @@ function TopPostsCard({ posts }: { posts: BlogAnalyticsSchema["top_posts"] }) {
);
}
function UsersSection() {
const [filters, setFilters] = React.useState<DateRangeState>({ from: "", to: "" });
function UsersSection({
filters,
onFiltersChange,
}: {
filters: DateRangeState;
onFiltersChange: (filters: DateRangeState) => void;
}) {
const query = useQuery({
queryKey: ["admin", "analytics", "users", filters],
queryFn: () => api.getAdminUserAnalytics({ date_from: filters.from || undefined, date_to: filters.to || undefined }),
@@ -709,7 +735,7 @@ function UsersSection() {
return (
<div className="space-y-6" dir="rtl">
<FilterCard title="فیلتر کاربران" description="این فیلتر فقط روی کاربران و تاریخ عضویت آن‌ها اعمال می‌شود.">
<DateRangeFilter value={filters} onChange={setFilters} onReset={() => setFilters({ from: "", to: "" })} />
<DateRangeFilter value={filters} onChange={onFiltersChange} onReset={() => onFiltersChange({ from: "", to: "" })} />
</FilterCard>
{query.isLoading ? <SectionLoading /> : null}
{query.isError ? <SectionError error={query.error} /> : null}
@@ -745,8 +771,13 @@ function UsersContent({ data }: { data: UserAnalyticsSchema }) {
);
}
function EventsSection() {
const [filters, setFilters] = React.useState<SectionState>({ from: "", to: "", eventId: null });
function EventsSection({
filters,
onFiltersChange,
}: {
filters: SectionState;
onFiltersChange: (filters: SectionState) => void;
}) {
const query = useQuery({
queryKey: ["admin", "analytics", "events", filters],
queryFn: () =>
@@ -757,7 +788,7 @@ function EventsSection() {
}),
});
const reset = () => setFilters({ from: "", to: "", eventId: null });
const reset = () => onFiltersChange({ from: "", to: "", eventId: null });
return (
<div className="space-y-6">
@@ -766,7 +797,7 @@ function EventsSection() {
<div>
<DateRangeFilter
value={filters}
onChange={(next) => setFilters((current) => ({ ...current, ...next }))}
onChange={(next) => onFiltersChange({ ...filters, ...next })}
onReset={reset}
/>
</div>
@@ -774,7 +805,7 @@ function EventsSection() {
<Label>رویداد</Label>
<AsyncSearchableCombobox
value={filters.eventId ?? null}
onChange={(eventId) => setFilters((current) => ({ ...current, eventId }))}
onChange={(eventId) => onFiltersChange({ ...filters, eventId })}
loadOptions={async ({ search, limit, offset }) => {
const data = await api.getAdminDashboardEventOptions({ search, limit, offset });
return {
@@ -838,8 +869,13 @@ function EventsContent({ data }: { data: EventAnalyticsSchema }) {
);
}
function BlogSection() {
const [filters, setFilters] = React.useState<DateRangeState>({ from: "", to: "" });
function BlogSection({
filters,
onFiltersChange,
}: {
filters: DateRangeState;
onFiltersChange: (filters: DateRangeState) => void;
}) {
const query = useQuery({
queryKey: ["admin", "analytics", "blog", filters],
queryFn: () => api.getAdminBlogAnalytics({ date_from: filters.from || undefined, date_to: filters.to || undefined }),
@@ -848,7 +884,7 @@ function BlogSection() {
return (
<div className="space-y-6">
<FilterCard title="فیلتر بلاگ" description="این فیلتر فقط روی نوشته‌ها و تعاملات بلاگ اعمال می‌شود و به رویدادها وابسته نیست.">
<DateRangeFilter value={filters} onChange={setFilters} onReset={() => setFilters({ from: "", to: "" })} />
<DateRangeFilter value={filters} onChange={onFiltersChange} onReset={() => onFiltersChange({ from: "", to: "" })} />
</FilterCard>
{query.isLoading ? <SectionLoading /> : null}
{query.isError ? <SectionError error={query.error} /> : null}
@@ -882,6 +918,35 @@ function BlogContent({ data }: { data: BlogAnalyticsSchema }) {
}
export default function AdminDashboard() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [activeTab, setActiveTab] = React.useState<DashboardTab>(() => parseTab(searchParams.get("tab")));
const [usersFilters, setUsersFilters] = React.useState<DateRangeState>(() => readDateRange(searchParams, "users"));
const [eventsFilters, setEventsFilters] = React.useState<SectionState>(() => ({
...readDateRange(searchParams, "events"),
eventId: searchParams.get("event_id"),
}));
const [blogFilters, setBlogFilters] = React.useState<DateRangeState>(() => readDateRange(searchParams, "blog"));
React.useEffect(() => {
const params = new URLSearchParams();
params.set("tab", activeTab);
setParam(params, "users_from", usersFilters.from);
setParam(params, "users_to", usersFilters.to);
setParam(params, "events_from", eventsFilters.from);
setParam(params, "events_to", eventsFilters.to);
setParam(params, "event_id", eventsFilters.eventId);
setParam(params, "blog_from", blogFilters.from);
setParam(params, "blog_to", blogFilters.to);
const nextSearch = params.toString();
const currentSearch = searchParams.toString();
if (nextSearch !== currentSearch) {
router.replace(`${pathname}?${nextSearch}`, { scroll: false });
}
}, [activeTab, blogFilters, eventsFilters, pathname, router, searchParams, usersFilters]);
return (
<div className="space-y-6" dir="rtl">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -893,7 +958,7 @@ export default function AdminDashboard() {
</div>
</div>
<Tabs dir="rtl" defaultValue="users" className="space-y-6">
<Tabs dir="rtl" value={activeTab} onValueChange={(value) => setActiveTab(parseTab(value))} 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" />
@@ -909,13 +974,13 @@ export default function AdminDashboard() {
</TabsTrigger>
</TabsList>
<TabsContent value="users">
<UsersSection />
<UsersSection filters={usersFilters} onFiltersChange={setUsersFilters} />
</TabsContent>
<TabsContent value="events">
<EventsSection />
<EventsSection filters={eventsFilters} onFiltersChange={setEventsFilters} />
</TabsContent>
<TabsContent value="blog">
<BlogSection />
<BlogSection filters={blogFilters} onFiltersChange={setBlogFilters} />
</TabsContent>
</Tabs>
</div>