feat: global platform v2 update
This commit is contained in:
501
app/dashboard/[workspaceId]/analytics/spending/page.tsx
Normal file
501
app/dashboard/[workspaceId]/analytics/spending/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user