feat: add header channel selector & adding channel modal menu

This commit is contained in:
ivannoskov
2025-12-01 05:57:28 +03:00
parent d4672ea32b
commit d2cc1c39b3
22 changed files with 3473 additions and 892 deletions

View File

@@ -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>

View File

@@ -32,7 +32,8 @@ import {
ShoppingCart,
DollarSign,
} from "lucide-react";
import type { AnalyticsOverview } from "@/lib/types/api";
import type { SpendingAnalytics } from "@/lib/types/api";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
import {
LineChart,
Line,
@@ -47,19 +48,29 @@ import {
} from "recharts";
export default function AnalyticsPage() {
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
const { selectedChannel } = useTargetChannel();
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadOverview();
}, []);
if (selectedChannel) {
loadData();
} else {
setLoading(false);
}
}, [selectedChannel]);
const loadData = async () => {
if (!selectedChannel) return;
const loadOverview = async () => {
try {
setLoading(true);
const data = await analyticsApi.overview();
setOverview(data);
const data = await analyticsApi.spending({
grouping: "week",
target_channel_id: selectedChannel.id,
});
setSpending(data);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки аналитики");
} finally {
@@ -83,7 +94,7 @@ export default function AnalyticsPage() {
);
}
if (error || !overview) {
if (error || !spending) {
return (
<>
<DashboardHeader
@@ -125,34 +136,17 @@ export default function AnalyticsPage() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Всего закупов
Всего затрат
</CardTitle>
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(overview.totalPurchases)}
</div>
<div className="flex items-center text-xs mt-1">
{overview.purchasesChange >= 0 ? (
<TrendingUp className="h-3 w-3 text-green-600 mr-1" />
) : (
<TrendingDown className="h-3 w-3 text-red-600 mr-1" />
)}
<span
className={
overview.purchasesChange >= 0
? "text-green-600"
: "text-red-600"
}
>
{overview.purchasesChange >= 0 ? "+" : ""}
{formatNumber(overview.purchasesChange)}
</span>
<span className="text-muted-foreground ml-1">
за прошлый месяц
</span>
{formatCurrency(spending.total_cost)}
</div>
<p className="text-xs text-muted-foreground mt-1">
За выбранный период
</p>
</CardContent>
</Card>
@@ -165,28 +159,11 @@ export default function AnalyticsPage() {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(overview.totalFollowers)}
</div>
<div className="flex items-center text-xs mt-1">
{overview.followersChange >= 0 ? (
<TrendingUp className="h-3 w-3 text-green-600 mr-1" />
) : (
<TrendingDown className="h-3 w-3 text-red-600 mr-1" />
)}
<span
className={
overview.followersChange >= 0
? "text-green-600"
: "text-red-600"
}
>
{overview.followersChange >= 0 ? "+" : ""}
{formatNumber(overview.followersChange)}
</span>
<span className="text-muted-foreground ml-1">
за прошлый месяц
</span>
{formatNumber(spending.total_subscriptions)}
</div>
<p className="text-xs text-muted-foreground mt-1">
Всего привлечено
</p>
</CardContent>
</Card>
@@ -197,151 +174,68 @@ export default function AnalyticsPage() {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatMetric(overview.averageCpf)}
</div>
<div className="flex items-center text-xs mt-1">
{overview.cpfChange <= 0 ? (
<TrendingDown className="h-3 w-3 text-green-600 mr-1" />
) : (
<TrendingUp className="h-3 w-3 text-red-600 mr-1" />
)}
<span
className={
overview.cpfChange <= 0 ? "text-green-600" : "text-red-600"
}
>
{formatPercent(overview.cpfChange, true)}
</span>
<span className="text-muted-foreground ml-1">
от прошлого месяца
</span>
{spending.avg_cpf ? `${formatMetric(spending.avg_cpf)}` : "—"}
</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">Средний CPM</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatMetric(overview.averageCpm)}
</div>
<div className="flex items-center text-xs mt-1">
{overview.cpmChange <= 0 ? (
<TrendingDown className="h-3 w-3 text-green-600 mr-1" />
) : (
<TrendingUp className="h-3 w-3 text-red-600 mr-1" />
)}
<span
className={
overview.cpmChange <= 0 ? "text-green-600" : "text-red-600"
}
>
{formatPercent(overview.cpmChange, true)}
</span>
<span className="text-muted-foreground ml-1">
от прошлого месяца
</span>
{spending.avg_cpm ? `${formatMetric(spending.avg_cpm)}` : "—"}
</div>
<p className="text-xs text-muted-foreground mt-1">
За 1000 просмотров
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Топ каналов по CPF</CardTitle>
<CardDescription>
Самые эффективные внешние каналы
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{overview.topChannelsByCpf?.length > 0 ? (
overview.topChannelsByCpf.slice(0, 5).map((item, index) => (
<div
key={item.channel_id}
className="flex items-center gap-3"
>
<Badge
variant="outline"
className="w-8 h-8 rounded-full p-0 flex items-center justify-center"
>
{index + 1}
</Badge>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">
{item.channel_name}
</p>
<p className="text-xs text-muted-foreground">
{formatNumber(item.total_purchases)} закупов
</p>
</div>
<div className="text-right">
<p className="font-medium">
{formatMetric(item.avg_cpf)}
</p>
<p className="text-xs text-muted-foreground">CPF</p>
</div>
</div>
))
) : (
<p className="text-center text-muted-foreground py-4">
Нет данных
</p>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Динамика затрат</CardTitle>
<CardDescription>Расходы по периодам</CardDescription>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={spending.chart_data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="period"
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",
}}
/>
<Line
type="monotone"
dataKey="cost"
stroke="hsl(var(--primary))"
strokeWidth={2}
name="Затраты"
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Топ креативов</CardTitle>
<CardDescription>
Самые эффективные рекламные креативы
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{overview.topCreativesByCpf?.length > 0 ? (
overview.topCreativesByCpf.slice(0, 5).map((item, index) => (
<div
key={item.creative_id}
className="flex items-center gap-3"
>
<Badge
variant="outline"
className="w-8 h-8 rounded-full p-0 flex items-center justify-center"
>
{index + 1}
</Badge>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">
{item.creative_name}
</p>
<p className="text-xs text-muted-foreground">
{formatNumber(item.total_subscriptions)} подписчиков
</p>
</div>
<div className="text-right">
<p className="font-medium">
{formatMetric(item.avg_cpf)}
</p>
<p className="text-xs text-muted-foreground">CPF</p>
</div>
</div>
))
) : (
<p className="text-center text-muted-foreground py-4">
Нет данных
</p>
)}
</div>
</CardContent>
</Card>
</div>
</div>
</>
);

View File

@@ -29,32 +29,19 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { creativesApi, channelsApi } from "@/lib/api";
import { AlertCircle, Loader2, ArrowLeft, Info } from "lucide-react";
import type { TargetChannel } from "@/lib/types/api";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
export default function CreateCreativePage() {
const router = useRouter();
const [channels, setChannels] = useState<TargetChannel[]>([]);
const { selectedChannel } = useTargetChannel();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
name: "",
text: "",
target_channel_id: "",
});
useEffect(() => {
loadChannels();
}, []);
const loadChannels = async () => {
try {
const response = await channelsApi.list();
setChannels(response.target_channels.filter((c) => c.is_active));
} catch (err) {
console.error("Error loading channels:", err);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
@@ -75,14 +62,17 @@ export default function CreateCreativePage() {
return;
}
if (!formData.target_channel_id) {
setError("Выберите целевой канал");
if (!selectedChannel) {
setError("Выберите целевой канал в верхнем меню");
return;
}
try {
setIsLoading(true);
await creativesApi.create(formData);
await creativesApi.create({
...formData,
target_channel_id: selectedChannel.id,
});
router.push("/creatives");
} catch (err: any) {
setError(err?.error?.message || "Ошибка создания креатива");
@@ -151,26 +141,14 @@ export default function CreateCreativePage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="target_channel_id">Целевой канал *</Label>
<Select
value={formData.target_channel_id}
onValueChange={(value) =>
setFormData({ ...formData, target_channel_id: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Выберите канал" />
</SelectTrigger>
<SelectContent>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedChannel && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Креатив будет создан для канала: <strong>{selectedChannel.title}</strong>
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>

View File

@@ -36,8 +36,10 @@ import {
} from "lucide-react";
import type { Creative } from "@/lib/types/api";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
export default function CreativesPage() {
const { selectedChannel } = useTargetChannel();
const [creatives, setCreatives] = useState<Creative[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -46,13 +48,21 @@ export default function CreativesPage() {
);
useEffect(() => {
loadCreatives();
}, []);
if (selectedChannel) {
loadCreatives();
} else {
setLoading(false);
}
}, [selectedChannel]);
const loadCreatives = async () => {
if (!selectedChannel) return;
try {
setLoading(true);
const response = await creativesApi.list();
const response = await creativesApi.list({
target_channel_id: selectedChannel.id,
});
setCreatives(response.creatives);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки креативов");

View File

@@ -49,8 +49,10 @@ import {
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
export default function ExternalChannelsPage() {
const { selectedChannel } = useTargetChannel();
const [channels, setChannels] = useState<ExternalChannel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -68,23 +70,20 @@ export default function ExternalChannelsPage() {
});
useEffect(() => {
loadChannels();
}, []);
if (selectedChannel) {
loadChannels();
} else {
setLoading(false);
}
}, [selectedChannel]);
const loadChannels = async () => {
if (!selectedChannel) return;
try {
setLoading(true);
// Сначала загружаем target channels
const targetChannelsRes = await channelsApi.list();
if (targetChannelsRes.target_channels.length === 0) {
setError("Добавьте сначала целевой канал");
setLoading(false);
return;
}
// Загружаем external channels для первого target channel
const firstTargetChannel = targetChannelsRes.target_channels[0];
const response = await externalChannelsApi.list(firstTargetChannel.id);
setError(null);
const response = await externalChannelsApi.list(selectedChannel.id);
setChannels(response.external_channels);
} catch (err: any) {
const errorMsg =

View File

@@ -27,26 +27,53 @@ import {
ShoppingCart,
Loader2,
} from "lucide-react";
import type { AnalyticsOverview } from "@/lib/types/api";
import type { SpendingAnalytics } from "@/lib/types/api";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
export default function DashboardPage() {
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
const { selectedChannel } = useTargetChannel();
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadOverview = async () => {
if (!selectedChannel) {
setLoading(false);
return;
}
const loadData = async () => {
try {
const data = await analyticsApi.overview();
setOverview(data);
setLoading(true);
const data = await analyticsApi.spending({
grouping: "week",
target_channel_id: selectedChannel.id,
});
setSpending(data);
} catch (error) {
console.error("Error loading overview:", error);
console.error("Error loading spending data:", error);
} finally {
setLoading(false);
}
};
loadOverview();
}, []);
loadData();
}, [selectedChannel]);
if (!selectedChannel) {
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-semibold mb-2">Выберите целевой канал</h2>
<p className="text-muted-foreground">
Для начала работы выберите целевой канал в верхнем меню
</p>
</div>
</div>
</>
);
}
return (
<>
@@ -66,10 +93,10 @@ export default function DashboardPage() {
) : (
<>
<div className="text-2xl font-bold">
{formatNumber(overview?.totalPurchases)}
{formatNumber(spending?.total_cost)}
</div>
<p className="text-xs text-muted-foreground">
{formatNumber(overview?.purchasesChange || 0, true)} за
{formatNumber(0 || 0, true)} за
прошлый месяц
</p>
</>
@@ -90,10 +117,10 @@ export default function DashboardPage() {
) : (
<>
<div className="text-2xl font-bold">
{formatNumber(overview?.totalFollowers)}
{formatNumber(spending?.total_subscriptions)}
</div>
<p className="text-xs text-muted-foreground">
{formatNumber(overview?.followersChange || 0, true)} за
{formatNumber(0 || 0, true)} за
прошлый месяц
</p>
</>
@@ -112,10 +139,10 @@ export default function DashboardPage() {
) : (
<>
<div className="text-2xl font-bold">
{formatMetric(overview?.averageCpf)}
{formatMetric(spending?.avg_cpf)}
</div>
<p className="text-xs text-muted-foreground">
{formatPercent(overview?.cpfChange || 0, true)} от прошлого
{formatPercent(0 || 0, true)} от прошлого
месяца
</p>
</>
@@ -134,10 +161,10 @@ export default function DashboardPage() {
) : (
<>
<div className="text-2xl font-bold">
{formatMetric(overview?.averageCpm)}
{formatMetric(spending?.avg_cpm)}
</div>
<p className="text-xs text-muted-foreground">
{formatPercent(overview?.cpmChange || 0, true)} от прошлого
{formatPercent(0 || 0, true)} от прошлого
месяца
</p>
</>

View File

@@ -42,11 +42,12 @@ import {
Copy,
Check,
} from "lucide-react";
import type { TargetChannel, ExternalChannel, Creative } from "@/lib/types/api";
import type { ExternalChannel, Creative } from "@/lib/types/api";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
export default function CreatePurchasePage() {
const router = useRouter();
const [channels, setChannels] = useState<TargetChannel[]>([]);
const { selectedChannel } = useTargetChannel();
const [externalChannels, setExternalChannels] = useState<ExternalChannel[]>(
[]
);
@@ -57,7 +58,6 @@ export default function CreatePurchasePage() {
const [copied, setCopied] = useState(false);
const [formData, setFormData] = useState({
target_channel_id: "",
external_channel_id: "",
creative_id: "",
placement_date: "",
@@ -68,41 +68,28 @@ export default function CreatePurchasePage() {
});
useEffect(() => {
loadData();
}, []);
if (selectedChannel) {
loadData();
}
}, [selectedChannel]);
const loadData = async () => {
try {
const channelsRes = await channelsApi.list();
setChannels(channelsRes.target_channels.filter((c) => c.is_active));
if (!selectedChannel) return;
// Загружаем внешние каналы для первого доступного target channel
if (channelsRes.target_channels.length > 0) {
const firstTargetChannel = channelsRes.target_channels[0];
const [externalChannelsRes, creativesRes] = await Promise.all([
externalChannelsApi.list(firstTargetChannel.id),
creativesApi.list(),
]);
setExternalChannels(externalChannelsRes.external_channels);
setCreatives(
creativesRes.creatives.filter((c) => c.status === "active")
);
} else {
setCreatives([]);
setExternalChannels([]);
}
try {
const [externalChannelsRes, creativesRes] = await Promise.all([
externalChannelsApi.list(selectedChannel.id),
creativesApi.list({ target_channel_id: selectedChannel.id }),
]);
setExternalChannels(externalChannelsRes.external_channels);
setCreatives(
creativesRes.creatives.filter((c) => c.status === "active")
);
} catch (err) {
console.error("Error loading data:", err);
}
};
// Фильтр креативов по выбранному целевому каналу
const filteredCreatives = formData.target_channel_id
? creatives.filter(
(c) => c.target_channel_id === formData.target_channel_id
)
: creatives;
const selectedCreative = creatives.find((c) => c.id === formData.creative_id);
const handleSubmit = async (e: React.FormEvent) => {
@@ -110,8 +97,8 @@ export default function CreatePurchasePage() {
setError(null);
// Validation
if (!formData.target_channel_id) {
setError("Выберите целевой канал");
if (!selectedChannel) {
setError("Выберите целевой канал в верхнем меню");
return;
}
@@ -138,7 +125,7 @@ export default function CreatePurchasePage() {
try {
setIsLoading(true);
const result = await purchasesApi.create({
target_channel_id: formData.target_channel_id,
target_channel_id: selectedChannel.id,
external_channel_id: formData.external_channel_id,
creative_id: formData.creative_id,
placement_date: formData.placement_date,
@@ -253,7 +240,6 @@ export default function CreatePurchasePage() {
onClick={() => {
setInviteLink("");
setFormData({
target_channel_id: "",
external_channel_id: "",
creative_id: "",
placement_date: "",
@@ -315,42 +301,29 @@ export default function CreatePurchasePage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="target_channel_id">Целевой канал *</Label>
<Select
value={formData.target_channel_id}
onValueChange={(value) =>
setFormData({
...formData,
target_channel_id: value,
creative_id: "", // Сбросить креатив при смене канала
})
}
>
<SelectTrigger>
<SelectValue placeholder="Выберите канал" />
</SelectTrigger>
<SelectContent>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedChannel && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Размещение будет создано для канала: <strong>{selectedChannel.title}</strong>
</AlertDescription>
</Alert>
)}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="external_channel_id">Внешний канал *</Label>
<Select
value={formData.external_channel_id}
onValueChange={(value) =>
setFormData({ ...formData, external_channel_id: value })
setFormData({
...formData,
external_channel_id: value,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Выберите канал" />
<SelectValue placeholder="Выберите внешний канал" />
</SelectTrigger>
<SelectContent>
{externalChannels.map((channel) => (
@@ -361,34 +334,27 @@ export default function CreatePurchasePage() {
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="creative_id">Креатив *</Label>
<Select
value={formData.creative_id}
onValueChange={(value) =>
setFormData({ ...formData, creative_id: value })
}
disabled={!formData.target_channel_id}
<div className="space-y-2">
<Label htmlFor="creative_id">Креатив *</Label>
<Select
value={formData.creative_id}
onValueChange={(value) =>
setFormData({ ...formData, creative_id: value })
}
>
<SelectTrigger>
<SelectValue
placeholder={
formData.target_channel_id
? "Выберите креатив"
: "Сначала выберите целевой канал"
}
/>
</SelectTrigger>
<SelectContent>
{filteredCreatives.map((creative) => (
<SelectItem key={creative.id} value={creative.id}>
{creative.name}
</SelectItem>
))}
</SelectContent>
</Select>
<SelectTrigger>
<SelectValue placeholder="Выберите креатив" />
</SelectTrigger>
<SelectContent>
{creatives.map((creative) => (
<SelectItem key={creative.id} value={creative.id}>
{creative.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>

View File

@@ -52,31 +52,35 @@ import {
Clock,
Archive,
} from "lucide-react";
import type { Purchase, TargetChannel } from "@/lib/types/api";
import type { Purchase } from "@/lib/types/api";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
export default function PurchasesPage() {
const { selectedChannel } = useTargetChannel();
const [purchases, setPurchases] = useState<Purchase[]>([]);
const [channels, setChannels] = useState<TargetChannel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [filterChannel, setFilterChannel] = useState<string>("all");
const [filterStatus, setFilterStatus] = useState<string>("all");
useEffect(() => {
loadData();
}, []);
if (selectedChannel) {
loadData();
} else {
setLoading(false);
}
}, [selectedChannel]);
const loadData = async () => {
if (!selectedChannel) return;
try {
setLoading(true);
const [purchasesRes, channelsRes] = await Promise.all([
purchasesApi.list(),
channelsApi.list(),
]);
const purchasesRes = await purchasesApi.list({
target_channel_id: selectedChannel.id,
});
setPurchases(purchasesRes.placements);
setChannels(channelsRes.target_channels);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки данных");
} finally {
@@ -90,15 +94,8 @@ export default function PurchasesPage() {
purchase.external_channel_title
.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
purchase.target_channel_title
.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
purchase.creative_name.toLowerCase().includes(searchQuery.toLowerCase());
// Фильтр по каналу
const matchesChannel =
filterChannel === "all" || purchase.target_channel_id === filterChannel;
// Фильтр по статусу
let matchesStatus = true;
if (filterStatus === "archived") {
@@ -107,7 +104,7 @@ export default function PurchasesPage() {
matchesStatus = purchase.status === "active";
}
return matchesSearch && matchesChannel && matchesStatus;
return matchesSearch && matchesStatus;
});
// Статистика
@@ -215,20 +212,6 @@ export default function PurchasesPage() {
/>
</div>
<Select value={filterChannel} onValueChange={setFilterChannel}>
<SelectTrigger>
<SelectValue placeholder="Целевой канал" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все каналы</SelectItem>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.title}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger>
<SelectValue placeholder="Статус" />
@@ -264,16 +247,12 @@ export default function PurchasesPage() {
<div className="text-center py-12">
<ShoppingCart className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-lg font-medium text-muted-foreground">
{searchQuery ||
filterChannel !== "all" ||
filterStatus !== "all"
{searchQuery || filterStatus !== "all"
? "Закупы не найдены"
: "Нет закупов"}
</p>
<p className="text-sm text-muted-foreground mt-1">
{searchQuery ||
filterChannel !== "all" ||
filterStatus !== "all"
{searchQuery || filterStatus !== "all"
? "Попробуйте изменить фильтры"
: "Создайте первый закуп"}
</p>

View File

@@ -7,6 +7,7 @@
import React from "react";
import { AuthProvider } from "@/components/auth/auth-provider";
import { ThemeProvider } from "@/components/theme-provider";
import { TargetChannelProvider } from "@/components/providers/target-channel-provider";
interface ProvidersProps {
children: React.ReactNode;
@@ -20,7 +21,9 @@ export const Providers: React.FC<ProvidersProps> = ({ children }) => {
enableSystem
disableTransitionOnChange
>
<AuthProvider>{children}</AuthProvider>
<AuthProvider>
<TargetChannelProvider>{children}</TargetChannelProvider>
</AuthProvider>
</ThemeProvider>
);
};