feat: add header channel selector & adding channel modal menu
This commit is contained in:
@@ -21,11 +21,11 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { analyticsApi, channelsApi } from "@/lib/api";
|
||||
import { analyticsApi } 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 type { SpendingAnalytics, DateGrouping } from "@/lib/types/api";
|
||||
import { useTargetChannel } from "@/components/providers/target-channel-provider";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
@@ -40,32 +40,30 @@ import {
|
||||
} from "recharts";
|
||||
|
||||
export default function AnalyticsCostsPage() {
|
||||
const [report, setReport] = useState<CostsReport | null>(null);
|
||||
const [channels, setChannels] = useState<TargetChannel[]>([]);
|
||||
const { selectedChannel } = useTargetChannel();
|
||||
const [report, setReport] = useState<SpendingAnalytics | null>(null);
|
||||
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
|
||||
);
|
||||
const [grouping, setGrouping] = useState<DateGrouping>("week");
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [period, targetChannelId]);
|
||||
if (selectedChannel) {
|
||||
loadData();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [grouping, selectedChannel]);
|
||||
|
||||
const loadData = async () => {
|
||||
if (!selectedChannel) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const [reportData, channelsData] = await Promise.all([
|
||||
analyticsApi.costs({ period, target_channel_id: targetChannelId }),
|
||||
channels.length > 0
|
||||
? Promise.resolve({ target_channels: channels })
|
||||
: channelsApi.list(),
|
||||
]);
|
||||
const reportData = await analyticsApi.spending({
|
||||
grouping,
|
||||
target_channel_id: selectedChannel.id,
|
||||
});
|
||||
setReport(reportData);
|
||||
if (channels.length === 0) {
|
||||
setChannels(channelsData.target_channels);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.error?.message || "Ошибка загрузки аналитики");
|
||||
} finally {
|
||||
@@ -106,47 +104,25 @@ export default function AnalyticsCostsPage() {
|
||||
<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 className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Период группировки
|
||||
</label>
|
||||
<Select
|
||||
value={grouping}
|
||||
onValueChange={(value: DateGrouping) => setGrouping(value)}
|
||||
>
|
||||
<SelectTrigger className="max-w-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="day">По дням</SelectItem>
|
||||
<SelectItem value="week">По неделям</SelectItem>
|
||||
<SelectItem value="month">По месяцам</SelectItem>
|
||||
<SelectItem value="quarter">По кварталам</SelectItem>
|
||||
<SelectItem value="year">По годам</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -159,7 +135,7 @@ export default function AnalyticsCostsPage() {
|
||||
</Card>
|
||||
) : report ? (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
@@ -169,7 +145,7 @@ export default function AnalyticsCostsPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(report.totalCost)}
|
||||
{formatCurrency(report.total_cost)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
За выбранный период
|
||||
@@ -180,15 +156,15 @@ export default function AnalyticsCostsPage() {
|
||||
<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)}
|
||||
{formatNumber(report.total_subscriptions)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
В среднем за период
|
||||
Всего привлечено
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -196,15 +172,31 @@ export default function AnalyticsCostsPage() {
|
||||
<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)}
|
||||
{formatNumber(report.total_views)}
|
||||
</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">
|
||||
Средний CPF
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{report.avg_cpf ? formatCurrency(report.avg_cpf) : "—"}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
В среднем за период
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -217,13 +209,13 @@ export default function AnalyticsCostsPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<BarChart data={report.periods}>
|
||||
<BarChart data={report.chart_data}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-muted"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
dataKey="period"
|
||||
className="text-xs"
|
||||
tick={{ fill: "hsl(var(--muted-foreground))" }}
|
||||
/>
|
||||
@@ -245,7 +237,7 @@ export default function AnalyticsCostsPage() {
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="total_cost"
|
||||
dataKey="cost"
|
||||
name="Затраты"
|
||||
fill="hsl(var(--primary))"
|
||||
radius={[8, 8, 0, 0]}
|
||||
@@ -262,23 +254,23 @@ export default function AnalyticsCostsPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{report.periods.map((period) => (
|
||||
{report.chart_data.map((period) => (
|
||||
<div
|
||||
key={period.date}
|
||||
key={period.period}
|
||||
className="flex items-center justify-between p-3 rounded-lg border"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{period.date}</p>
|
||||
<p className="font-medium">{period.period}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatNumber(period.purchases_count)} закупов
|
||||
{formatNumber(period.subscriptions)} подписок • {formatNumber(period.views)} просмотров
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-bold">
|
||||
{formatCurrency(period.total_cost)}
|
||||
{formatCurrency(period.cost)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
ср. ₽{formatNumber(period.avg_cost)}
|
||||
{period.cpf ? `CPF: ${formatCurrency(period.cpf)}` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user