feat(admin-dashboard): add full result drilldown modals
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user