8f2fd89847
Build & Push Docker Image / build (push) Successful in 2m49s
- Add POST /admin/tools/import with format auto-detect, CSV delimiters
(comma/semicolon/tab), per-row validation via CreateToolBody, bulk insert,
audit log entries per imported tool; gated by new 'tool-import' feature
flag (premium/enterprise; admins always pass)
- Add tool-import-dialog UI (format tabs, delimiter select, textarea, file
upload, result/error list) behind hasFeature('tool-import')
- Replace FieldHelp question marks and bare GuideHelp links with a NetBox-style
'Hilfe/Help' outline button (HelpCircle + text) in form headers only
- Sync locales to 482 keys per language (de/en), update handbook docs
(administration import section, index/plaene feature tables), regenerate
API client + zod schemas, add yaml dependency
214 lines
10 KiB
TypeScript
214 lines
10 KiB
TypeScript
import {
|
|
useGetAnalyticsSummary,
|
|
useGetTopTools,
|
|
useGetAnalyticsByCategory,
|
|
useGetRatingDistribution,
|
|
GetTopToolsMetric
|
|
} from "@workspace/api-client-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Layout } from "@/components/layout";
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import {
|
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer,
|
|
RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Legend,
|
|
LineChart, Line
|
|
} from "recharts";
|
|
import { BarChart3, TrendingUp, Layers, Activity } from "lucide-react";
|
|
|
|
export default function Analytics() {
|
|
const { t } = useTranslation();
|
|
const { data: summary, isLoading: loadingSummary } = useGetAnalyticsSummary();
|
|
|
|
const { data: topTools, isLoading: loadingTopTools } = useGetTopTools({
|
|
limit: 8,
|
|
metric: GetTopToolsMetric.combined
|
|
});
|
|
|
|
const { data: categoryStats, isLoading: loadingCategories } = useGetAnalyticsByCategory();
|
|
|
|
const { data: distribution, isLoading: loadingDistribution } = useGetRatingDistribution();
|
|
|
|
const topToolsChartData = topTools?.map(t => ({
|
|
name: t.tool.name,
|
|
score: Number((t.score).toFixed(2)),
|
|
ratings: t.ratingCount
|
|
})) || [];
|
|
|
|
const categoryChartData = categoryStats?.map(c => ({
|
|
category: c.category,
|
|
tools: c.toolCount,
|
|
avgScore: (c.avgUsefulness != null && c.avgUsability != null)
|
|
? Number(((c.avgUsefulness + c.avgUsability) / 2).toFixed(2))
|
|
: 0
|
|
})) || [];
|
|
|
|
const usefulnessData = distribution?.usefulness.map(b => ({ score: `${b.score} Star`, count: b.count })) || [];
|
|
const usabilityData = distribution?.usability.map(b => ({ score: `${b.score} Star`, count: b.count })) || [];
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="space-y-6 pb-10">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
|
{t("analytics.title")}
|
|
</h1>
|
|
<p className="text-muted-foreground">{t("analytics.subtitle")}</p>
|
|
</div>
|
|
|
|
{/* Top KPI Cards */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<span className="text-sm font-medium text-muted-foreground">{t("analytics.totalToolsIndexed")}</span>
|
|
<Layers className="w-4 h-4 text-muted-foreground" />
|
|
</div>
|
|
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
|
<div className="text-3xl font-bold">{summary?.totalTools || 0}</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<span className="text-sm font-medium text-muted-foreground">{t("analytics.totalRatingsCast")}</span>
|
|
<Activity className="w-4 h-4 text-muted-foreground" />
|
|
</div>
|
|
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
|
<div className="text-3xl font-bold">{summary?.totalRatings || 0}</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<span className="text-sm font-medium text-muted-foreground">{t("analytics.activeCategories")}</span>
|
|
<BarChart3 className="w-4 h-4 text-muted-foreground" />
|
|
</div>
|
|
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
|
<div className="text-3xl font-bold">{summary?.categoriesCount || 0}</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<span className="text-sm font-medium text-muted-foreground">{t("analytics.avgGlobalScore")}</span>
|
|
<TrendingUp className="w-4 h-4 text-muted-foreground" />
|
|
</div>
|
|
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
|
<div className="text-3xl font-bold">{summary?.avgCombined?.toFixed(2) || "0.00"}</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
|
|
{/* Top Tools Chart */}
|
|
<Card className="col-span-1 lg:col-span-2">
|
|
<CardHeader>
|
|
<CardTitle>{t("analytics.top8Tools")}</CardTitle>
|
|
<CardDescription>{t("analytics.top8ToolsSub")}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loadingTopTools ? (
|
|
<Skeleton className="h-[300px] w-full" />
|
|
) : (
|
|
<div className="h-[300px] w-full">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={topToolsChartData} margin={{ top: 20, right: 30, left: 0, bottom: 5 }}>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
|
<XAxis dataKey="name" tickLine={false} axisLine={false} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />
|
|
<YAxis domain={[0, 5]} tickLine={false} axisLine={false} tickFormatter={(v) => `${v}`} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />
|
|
<RechartsTooltip
|
|
cursor={{ fill: 'hsl(var(--muted))' }}
|
|
contentStyle={{ borderRadius: '8px', border: '1px solid hsl(var(--border))' }}
|
|
/>
|
|
<Bar dataKey="score" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} maxBarSize={50} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Category Breakdown */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t("analytics.toolsByCategory")}</CardTitle>
|
|
<CardDescription>{t("analytics.toolsByCategorySub")}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loadingCategories ? (
|
|
<Skeleton className="h-[300px] w-full" />
|
|
) : (
|
|
<div className="h-[300px] w-full">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={categoryChartData}>
|
|
<PolarGrid stroke="hsl(var(--border))" />
|
|
<PolarAngleAxis dataKey="category" tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />
|
|
<PolarRadiusAxis angle={30} domain={[0, 'auto']} tick={false} axisLine={false} />
|
|
<Radar name={t("analytics.radarTools")} dataKey="tools" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.3} />
|
|
<RechartsTooltip />
|
|
</RadarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Rating Distributions */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t("analytics.globalRatingDistribution")}</CardTitle>
|
|
<CardDescription>{t("analytics.globalRatingDistributionSub")}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loadingDistribution ? (
|
|
<Skeleton className="h-[280px] w-full" />
|
|
) : (
|
|
<div className="space-y-5">
|
|
<div>
|
|
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">{t("detail.usefulness")}</span>
|
|
<div className="h-[110px] w-full">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={usefulnessData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
|
<XAxis dataKey="score" tickLine={false} axisLine={false} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />
|
|
<YAxis hide />
|
|
<RechartsTooltip cursor={{ fill: 'hsl(var(--muted))' }} contentStyle={{ borderRadius: '6px', border: '1px solid hsl(var(--border))' }} />
|
|
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} maxBarSize={40} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">{t("detail.usability")}</span>
|
|
<div className="h-[110px] w-full">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={usabilityData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
|
<XAxis dataKey="score" tickLine={false} axisLine={false} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />
|
|
<YAxis hide />
|
|
<RechartsTooltip cursor={{ fill: 'hsl(var(--muted))' }} contentStyle={{ borderRadius: '6px', border: '1px solid hsl(var(--border))' }} />
|
|
<Bar dataKey="count" fill="hsl(var(--chart-3))" radius={[4, 4, 0, 0]} maxBarSize={40} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
</div>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|