feat: global platform v2 update

This commit is contained in:
ivannoskov
2025-12-29 10:12:13 +03:00
parent 8e6a1fa83f
commit f64cf02100
84 changed files with 11023 additions and 11486 deletions

View File

@@ -0,0 +1,501 @@
"use client";
// ============================================================================
// Spending Analytics Page - Enhanced with Charts
// ============================================================================
import { useEffect, useState, useMemo } from "react";
import { useParams } from "next/navigation";
import { Loader2, TrendingUp, BarChart3, LineChart as LineChartIcon, Info } from "lucide-react";
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
LabelList,
} from "recharts";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwareAnalyticsApi } from "@/lib/demo";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
type ChartConfig,
} from "@/components/ui/chart";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import type { SpendingAnalytics, DateGrouping } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatCurrency = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatNumber = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
const formatShortCurrency = (value: number) => {
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `${(value / 1000).toFixed(0)}K`;
return value.toString();
};
const formatDateLabel = (date: string, grouping: DateGrouping) => {
const d = new Date(date);
const g = grouping.toLowerCase();
if (g === "day") {
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
} else if (g === "week") {
return `Нед. ${d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}`;
} else {
return d.toLocaleDateString("ru-RU", { month: "short", year: "2-digit" });
}
};
// ============================================================================
// Chart Types
// ============================================================================
type ChartType = "line" | "bar";
type MetricKey = "cost" | "subscriptions" | "views";
// ============================================================================
// Chart Configurations
// ============================================================================
const chartConfig: ChartConfig = {
cost: {
label: "Расходы",
color: "hsl(var(--chart-1))",
},
subscriptions: {
label: "Подписчики",
color: "hsl(var(--chart-2))",
},
views: {
label: "Просмотры",
color: "hsl(var(--chart-3))",
},
};
// ============================================================================
// Component
// ============================================================================
export default function SpendingAnalyticsPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { selectedProjects, getProjectFilterId, allProjectsSelected } = useWorkspace();
const projectFilterId = getProjectFilterId();
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
const [loading, setLoading] = useState(true);
const [grouping, setGrouping] = useState<DateGrouping>("month");
// Chart settings
const [chartType, setChartType] = useState<ChartType>("line");
const [showLabels, setShowLabels] = useState(false);
const [selectedMetrics, setSelectedMetrics] = useState<MetricKey[]>(["cost", "subscriptions"]);
useEffect(() => {
loadData();
}, [workspaceId, projectFilterId, grouping]);
const loadData = async () => {
try {
setLoading(true);
const data = await demoAwareAnalyticsApi.spending(workspaceId, {
project_id: projectFilterId,
grouping,
});
setSpending(data);
} catch (err) {
console.error("Failed to load spending:", err);
} finally {
setLoading(false);
}
};
// Format chart data
const chartData = useMemo(() => {
if (!spending?.chart_data) return [];
return spending.chart_data.map((item) => ({
period: item.period,
dateLabel: formatDateLabel(item.period, grouping),
cost: item.cost,
subscriptions: item.subscriptions,
views: item.views,
}));
}, [spending, grouping]);
// Toggle metric
const handleToggleMetric = (metric: MetricKey) => {
if (selectedMetrics.includes(metric)) {
if (selectedMetrics.length > 1) {
setSelectedMetrics(selectedMetrics.filter((m) => m !== metric));
}
} else {
setSelectedMetrics([...selectedMetrics, metric]);
}
};
// Render chart
const renderChart = () => {
if (chartData.length === 0) return null;
const ChartComponent = chartType === "line" ? LineChart : BarChart;
return (
<ChartContainer config={chartConfig} className="aspect-auto h-[300px] w-full">
<ChartComponent
data={chartData}
margin={{ left: 12, right: 12, top: showLabels ? 20 : 12, bottom: 12 }}
>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="dateLabel"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={formatShortCurrency}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
if (payload && payload[0]) {
return payload[0].payload.period;
}
return "";
}}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{selectedMetrics.map((metric) =>
chartType === "line" ? (
<Line
key={metric}
dataKey={metric}
type="monotone"
stroke={`var(--color-${metric})`}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
>
{showLabels && (
<LabelList
dataKey={metric}
position="top"
formatter={(value: number) =>
metric === "cost" ? formatShortCurrency(value) : formatNumber(value)
}
className="fill-foreground text-[10px]"
/>
)}
</Line>
) : (
<Bar
key={metric}
dataKey={metric}
fill={`var(--color-${metric})`}
radius={[4, 4, 0, 0]}
>
{showLabels && (
<LabelList
dataKey={metric}
position="top"
formatter={(value: number) =>
metric === "cost" ? formatShortCurrency(value) : formatNumber(value)
}
className="fill-foreground text-[10px]"
/>
)}
</Bar>
)
)}
</ChartComponent>
</ChartContainer>
);
};
return (
<TooltipProvider>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Аналитика", href: `/dashboard/${workspaceId}/analytics` },
{ label: "Расходы" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Динамика расходов</h1>
<p className="text-muted-foreground">Анализ расходов по периодам</p>
</div>
<Select value={grouping} onValueChange={(v) => setGrouping(v as DateGrouping)}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">По дням</SelectItem>
<SelectItem value="week">По неделям</SelectItem>
<SelectItem value="month">По месяцам</SelectItem>
</SelectContent>
</Select>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : spending ? (
<>
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Всего потрачено</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatCurrency(spending.total_cost)}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Подписчиков</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(spending.total_subscriptions)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Просмотров</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(spending.total_views)}</div>
</CardContent>
</Card>
</div>
{/* Chart with Settings */}
{chartData.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>График динамики</CardTitle>
<CardDescription>
Визуализация данных по выбранным метрикам
</CardDescription>
</div>
{/* Chart Settings */}
<div className="flex items-center gap-4">
{/* Chart Type Toggle */}
<div className="flex items-center gap-1 border rounded-lg p-1">
<Button
variant={chartType === "line" ? "secondary" : "ghost"}
size="sm"
onClick={() => setChartType("line")}
aria-label="Линейный график"
>
<LineChartIcon className="h-4 w-4" />
</Button>
<Button
variant={chartType === "bar" ? "secondary" : "ghost"}
size="sm"
onClick={() => setChartType("bar")}
aria-label="Столбчатая диаграмма"
>
<BarChart3 className="h-4 w-4" />
</Button>
</div>
{/* Labels Toggle */}
<div className="flex items-center gap-2">
<Checkbox
id="show-labels"
checked={showLabels}
onCheckedChange={(checked) => setShowLabels(checked === true)}
/>
<Label htmlFor="show-labels" className="text-sm cursor-pointer">
Подписи
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Показывать значения на точках графика
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
{/* Metrics Selection */}
<div className="flex items-center gap-4 mt-3 pt-3 border-t">
<span className="text-sm text-muted-foreground">Метрики:</span>
{(["cost", "subscriptions", "views"] as MetricKey[]).map((metric) => (
<div key={metric} className="flex items-center gap-2">
<Checkbox
id={`metric-${metric}`}
checked={selectedMetrics.includes(metric)}
onCheckedChange={() => handleToggleMetric(metric)}
/>
<Label
htmlFor={`metric-${metric}`}
className="text-sm cursor-pointer flex items-center gap-1.5"
>
<div
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: `var(--color-${metric})` }}
/>
{chartConfig[metric].label}
</Label>
</div>
))}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">{renderChart()}</CardContent>
</Card>
)}
{/* Data Table */}
{spending.chart_data && spending.chart_data.length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Данные по периодам</CardTitle>
<CardDescription>{spending.chart_data.length} периодов</CardDescription>
</CardHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Период</TableHead>
<TableHead className="text-right">Расходы</TableHead>
<TableHead className="text-right">Подписчики</TableHead>
<TableHead className="text-right">Просмотры</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
CPS
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>Cost Per Subscription</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
CPM
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>Cost Per Mille (1000 views)</TooltipContent>
</Tooltip>
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{spending.chart_data.map((item, index) => {
const cps = item.subscriptions > 0 ? item.cost / item.subscriptions : null;
const cpm = item.views > 0 ? (item.cost / item.views) * 1000 : null;
return (
<TableRow key={index}>
<TableCell className="font-medium">{item.period}</TableCell>
<TableCell className="text-right">{formatCurrency(item.cost)}</TableCell>
<TableCell className="text-right">
{formatNumber(item.subscriptions)}
</TableCell>
<TableCell className="text-right">{formatNumber(item.views)}</TableCell>
<TableCell className="text-right">
{cps ? formatCurrency(cps) : "—"}
</TableCell>
<TableCell className="text-right">
{cpm ? formatCurrency(cpm) : "—"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<TrendingUp className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет данных</h3>
<p className="text-muted-foreground text-center">
Создайте размещения для отображения статистики
</p>
</CardContent>
</Card>
)}
</>
) : null}
</div>
</TooltipProvider>
);
}