295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
"use client";
|
||
|
||
// ============================================================================
|
||
// Analytics Costs Page
|
||
// ============================================================================
|
||
|
||
import React, { useEffect, useState } from "react";
|
||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||
import { analyticsApi, channelsApi } from "@/lib/api";
|
||
import { formatCurrency, formatNumber } from "@/lib/utils/format";
|
||
import { Loader2, AlertCircle, DollarSign } from "lucide-react";
|
||
import type { CostsReport } from "@/lib/types/api";
|
||
import type { TargetChannel } from "@/lib/types/api";
|
||
import {
|
||
LineChart,
|
||
Line,
|
||
BarChart,
|
||
Bar,
|
||
XAxis,
|
||
YAxis,
|
||
CartesianGrid,
|
||
Tooltip,
|
||
Legend,
|
||
ResponsiveContainer,
|
||
} from "recharts";
|
||
|
||
export default function AnalyticsCostsPage() {
|
||
const [report, setReport] = useState<CostsReport | null>(null);
|
||
const [channels, setChannels] = useState<TargetChannel[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [period, setPeriod] = useState<"day" | "week" | "month">("week");
|
||
const [targetChannelId, setTargetChannelId] = useState<string | undefined>(
|
||
undefined
|
||
);
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
}, [period, targetChannelId]);
|
||
|
||
const loadData = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const [reportData, channelsData] = await Promise.all([
|
||
analyticsApi.costs({ period, target_channel_id: targetChannelId }),
|
||
channels.length > 0
|
||
? Promise.resolve({ data: channels })
|
||
: channelsApi.list({}),
|
||
]);
|
||
setReport(reportData);
|
||
if (channels.length === 0) {
|
||
setChannels(channelsData.data);
|
||
}
|
||
} catch (err: any) {
|
||
setError(err?.error?.message || "Ошибка загрузки аналитики");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<DashboardHeader
|
||
breadcrumbs={[
|
||
{ label: "Главная", href: "/" },
|
||
{ label: "Аналитика", href: "/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-3xl font-bold tracking-tight">
|
||
Затраты на рекламу
|
||
</h1>
|
||
<p className="text-muted-foreground mt-1">
|
||
Динамика расходов по периодам
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{error && (
|
||
<Alert variant="destructive">
|
||
<AlertCircle className="h-4 w-4" />
|
||
<AlertDescription>{error}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Фильтры</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium">
|
||
Период группировки
|
||
</label>
|
||
<Select
|
||
value={period}
|
||
onValueChange={(value: any) => setPeriod(value)}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="day">По дням</SelectItem>
|
||
<SelectItem value="week">По неделям</SelectItem>
|
||
<SelectItem value="month">По месяцам</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium">Целевой канал</label>
|
||
<Select
|
||
value={targetChannelId || "all"}
|
||
onValueChange={(value) =>
|
||
setTargetChannelId(value === "all" ? undefined : value)
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">Все каналы</SelectItem>
|
||
{channels.map((channel) => (
|
||
<SelectItem key={channel.id} value={channel.id}>
|
||
{channel.title}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{loading ? (
|
||
<Card>
|
||
<CardContent className="flex items-center justify-center p-12">
|
||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||
</CardContent>
|
||
</Card>
|
||
) : report ? (
|
||
<>
|
||
<div className="grid gap-4 md:grid-cols-3">
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||
<CardTitle className="text-sm font-medium">
|
||
Всего затрат
|
||
</CardTitle>
|
||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="text-2xl font-bold">
|
||
{formatCurrency(report.totalCost)}
|
||
</div>
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
За выбранный период
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||
<CardTitle className="text-sm font-medium">
|
||
Средние затраты
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="text-2xl font-bold">
|
||
{formatCurrency(report.averageCost)}
|
||
</div>
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
В среднем за период
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||
<CardTitle className="text-sm font-medium">
|
||
Периодов
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="text-2xl font-bold">
|
||
{formatNumber(report.periods.length)}
|
||
</div>
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
С активностью
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>График затрат</CardTitle>
|
||
<CardDescription>Динамика расходов на рекламу</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ResponsiveContainer width="100%" height={350}>
|
||
<BarChart data={report.periods}>
|
||
<CartesianGrid
|
||
strokeDasharray="3 3"
|
||
className="stroke-muted"
|
||
/>
|
||
<XAxis
|
||
dataKey="date"
|
||
className="text-xs"
|
||
tick={{ fill: "hsl(var(--muted-foreground))" }}
|
||
/>
|
||
<YAxis
|
||
className="text-xs"
|
||
tick={{ fill: "hsl(var(--muted-foreground))" }}
|
||
tickFormatter={(value) => `₽${formatNumber(value)}`}
|
||
/>
|
||
<Tooltip
|
||
contentStyle={{
|
||
backgroundColor: "hsl(var(--background))",
|
||
border: "1px solid hsl(var(--border))",
|
||
borderRadius: "8px",
|
||
}}
|
||
formatter={(value: any) => [
|
||
`₽${formatNumber(value)}`,
|
||
"Затраты",
|
||
]}
|
||
/>
|
||
<Legend />
|
||
<Bar
|
||
dataKey="total_cost"
|
||
name="Затраты"
|
||
fill="hsl(var(--primary))"
|
||
radius={[8, 8, 0, 0]}
|
||
/>
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Детализация по периодам</CardTitle>
|
||
<CardDescription>Затраты и количество закупов</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3">
|
||
{report.periods.map((period) => (
|
||
<div
|
||
key={period.date}
|
||
className="flex items-center justify-between p-3 rounded-lg border"
|
||
>
|
||
<div>
|
||
<p className="font-medium">{period.date}</p>
|
||
<p className="text-sm text-muted-foreground">
|
||
{formatNumber(period.purchases_count)} закупов
|
||
</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-lg font-bold">
|
||
{formatCurrency(period.total_cost)}
|
||
</p>
|
||
<p className="text-sm text-muted-foreground">
|
||
ср. ₽{formatNumber(period.avg_cost)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|