feat(admin-dashboard): add full result drilldown modals
This commit is contained in:
@@ -760,6 +760,7 @@ export interface AnalyticsPointSchema {
|
||||
}
|
||||
|
||||
export interface AnalyticsPointGroupSchema {
|
||||
items: AnalyticsPointSchema[];
|
||||
top_items: AnalyticsPointSchema[];
|
||||
other_count: number;
|
||||
total_count: number;
|
||||
@@ -797,6 +798,7 @@ export interface AnalyticsPostPopularitySchema {
|
||||
}
|
||||
|
||||
export interface AnalyticsPostPopularityGroupSchema {
|
||||
items: AnalyticsPostPopularitySchema[];
|
||||
top_items: AnalyticsPostPopularitySchema[];
|
||||
other_count: number;
|
||||
total_count: number;
|
||||
|
||||
@@ -41,6 +41,8 @@ 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 { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -152,6 +154,10 @@ function dataWithOtherNotice(group: AnalyticsPointGroupSchema) {
|
||||
return group.top_items;
|
||||
}
|
||||
|
||||
function fullGroupItems(group: AnalyticsPointGroupSchema) {
|
||||
return group.items?.length ? group.items : group.top_items;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
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;
|
||||
return (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
نمایش {formatNumberPersian(group.top_items.length)} مورد برتر از {formatNumberPersian(group.total_count)} مورد؛{" "}
|
||||
{formatNumberPersian(group.other_count)} مورد دیگر در نمودار فشرده نشدهاند.
|
||||
</p>
|
||||
<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">
|
||||
<span>
|
||||
نمودار خلاصه {formatNumberPersian(group.top_items.length)} مورد اول را نشان میدهد؛ همه{" "}
|
||||
{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;
|
||||
}) {
|
||||
const data = dataWithOtherNotice(group);
|
||||
const allData = fullGroupItems(group);
|
||||
const [detailsOpen, setDetailsOpen] = React.useState(false);
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -407,8 +526,18 @@ function HorizontalBarCard({
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</ChartViewport>
|
||||
<HighCardinalityNotice group={group} />
|
||||
<FullResultsAction group={group} onOpen={() => setDetailsOpen(true)} />
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user