feat(admin-dashboard): add full result drilldown modals

This commit is contained in:
2026-06-15 17:35:45 +03:30
parent 6ba8f6ec8b
commit 268dd26d9a
2 changed files with 137 additions and 6 deletions

View File

@@ -760,6 +760,7 @@ export interface AnalyticsPointSchema {
} }
export interface AnalyticsPointGroupSchema { export interface AnalyticsPointGroupSchema {
items: AnalyticsPointSchema[];
top_items: AnalyticsPointSchema[]; top_items: AnalyticsPointSchema[];
other_count: number; other_count: number;
total_count: number; total_count: number;
@@ -797,6 +798,7 @@ export interface AnalyticsPostPopularitySchema {
} }
export interface AnalyticsPostPopularityGroupSchema { export interface AnalyticsPostPopularityGroupSchema {
items: AnalyticsPostPopularitySchema[];
top_items: AnalyticsPostPopularitySchema[]; top_items: AnalyticsPostPopularitySchema[];
other_count: number; other_count: number;
total_count: number; total_count: number;

View File

@@ -41,6 +41,8 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"; import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@@ -152,6 +154,10 @@ function dataWithOtherNotice(group: AnalyticsPointGroupSchema) {
return group.top_items; return group.top_items;
} }
function fullGroupItems(group: AnalyticsPointGroupSchema) {
return group.items?.length ? group.items : group.top_items;
}
function StatCard({ function StatCard({
title, title,
value, value,
@@ -309,13 +315,124 @@ function CustomValueTooltip({
); );
} }
function HighCardinalityNotice({ group }: { group: AnalyticsPointGroupSchema }) { function FullResultsAction({
group,
onOpen,
}: {
group: AnalyticsPointGroupSchema;
onOpen: () => void;
}) {
if (group.total_count <= group.top_items.length) return null; if (group.total_count <= group.top_items.length) return null;
return ( return (
<p className="mt-2 text-xs text-muted-foreground"> <div className="mt-3 flex flex-col gap-2 rounded-xl border bg-muted/20 p-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
نمایش {formatNumberPersian(group.top_items.length)} مورد برتر از {formatNumberPersian(group.total_count)} مورد؛{" "} <span>
{formatNumberPersian(group.other_count)} مورد دیگر در نمودار فشرده نشدهاند. نمودار خلاصه {formatNumberPersian(group.top_items.length)} مورد اول را نشان میدهد؛ همه{" "}
</p> {formatNumberPersian(group.total_count)} مورد در نمای کامل قابل بررسی است.
</span>
<Button type="button" size="sm" variant="secondary" className="shrink-0" onClick={onOpen}>
مشاهده همه
</Button>
</div>
);
}
function DashboardPointDetailModal({
open,
onOpenChange,
title,
description,
data,
color,
unit,
formatter,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
data: AnalyticsPointSchema[];
color: string;
unit?: string;
formatter?: (value: number) => string;
}) {
const [search, setSearch] = React.useState("");
const [sortBy, setSortBy] = React.useState<"value" | "label">("value");
const filteredData = React.useMemo(() => {
const normalizedSearch = search.trim().toLocaleLowerCase("fa-IR");
const filtered = normalizedSearch
? data.filter((item) => toPersianDigits(item.label).toLocaleLowerCase("fa-IR").includes(normalizedSearch))
: data;
return [...filtered].sort((a, b) => {
if (sortBy === "label") return String(a.label).localeCompare(String(b.label), "fa");
return Number(b.value) - Number(a.value) || String(a.label).localeCompare(String(b.label), "fa");
});
}, [data, search, sortBy]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-5xl overflow-y-auto rounded-3xl" dir="rtl">
<DialogHeader className="mt-6 text-right md:text-right">
<DialogTitle>{title}</DialogTitle>
<p className="text-sm text-muted-foreground">{description}</p>
</DialogHeader>
<div className="grid gap-3 sm:grid-cols-[1fr_auto_auto]">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="جستجو در عنوان‌ها..."
className="text-right"
/>
<Button type="button" variant={sortBy === "value" ? "default" : "outline"} onClick={() => setSortBy("value")}>
مرتبسازی با مقدار
</Button>
<Button type="button" variant={sortBy === "label" ? "default" : "outline"} onClick={() => setSortBy("label")}>
مرتبسازی الفبایی
</Button>
</div>
{!filteredData.length ? (
<EmptyChart label="موردی با این جستجو پیدا نشد." />
) : (
<>
<ChartViewport minWidth={680}>
<ChartContainer
config={{ value: { label: "مقدار", color } }}
className="w-full"
style={{ height: Math.min(Math.max(filteredData.length * 34 + 100, 360), 780) }}
>
<BarChart
data={filteredData}
layout="vertical"
margin={{ top: 12, right: axisWidth(filteredData) + 16, bottom: 24, left: 20 }}
>
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
<XAxis
type="number"
reversed
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => formatter ? formatter(Number(value)) : formatNumberPersian(Number(value))}
/>
<YAxis
dataKey="label"
type="category"
orientation="right"
width={axisWidth(filteredData)}
tickLine={false}
axisLine={false}
tickMargin={10}
tickFormatter={(value) => truncateLabel(String(value), 24)}
/>
<ChartTooltip content={<CustomValueTooltip unit={unit} formatter={formatter} />} />
<Bar dataKey="value" radius={[8, 8, 8, 8]} fill="var(--color-value)" />
</BarChart>
</ChartContainer>
</ChartViewport>
<ValuesTable data={filteredData} unit={unit} formatter={formatter} />
</>
)}
</DialogContent>
</Dialog>
); );
} }
@@ -365,6 +482,8 @@ function HorizontalBarCard({
valueFormatter?: (value: number) => string; valueFormatter?: (value: number) => string;
}) { }) {
const data = dataWithOtherNotice(group); const data = dataWithOtherNotice(group);
const allData = fullGroupItems(group);
const [detailsOpen, setDetailsOpen] = React.useState(false);
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -407,8 +526,18 @@ function HorizontalBarCard({
</BarChart> </BarChart>
</ChartContainer> </ChartContainer>
</ChartViewport> </ChartViewport>
<HighCardinalityNotice group={group} /> <FullResultsAction group={group} onOpen={() => setDetailsOpen(true)} />
<ValuesTable data={data} unit={unit} formatter={valueFormatter} /> <ValuesTable data={data} unit={unit} formatter={valueFormatter} />
<DashboardPointDetailModal
open={detailsOpen}
onOpenChange={setDetailsOpen}
title={title}
description={`${description}؛ نمایش کامل ${formatNumberPersian(allData.length)} مورد`}
data={allData}
color={color}
unit={unit}
formatter={valueFormatter}
/>
</> </>
)} )}
</CardContent> </CardContent>