Files
tgex-frontend/app/(dashboard)/purchases/page.tsx
2025-11-10 12:07:24 +03:00

382 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// ============================================================================
// Purchases List Page
// ============================================================================
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { purchasesApi, channelsApi } from "@/lib/api";
import {
formatNumber,
formatMetric,
formatCurrency,
formatDate,
formatPercent,
} from "@/lib/utils/format";
import {
ShoppingCart,
Loader2,
AlertCircle,
Plus,
Search,
Filter,
Eye,
CheckCircle,
Clock,
Archive,
} from "lucide-react";
import type { Purchase, TargetChannel } from "@/lib/types/api";
import { Alert, AlertDescription } from "@/components/ui/alert";
export default function PurchasesPage() {
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();
}, []);
const loadData = async () => {
try {
setLoading(true);
const [purchasesRes, channelsRes] = await Promise.all([
purchasesApi.list(),
channelsApi.list({}),
]);
setPurchases(purchasesRes.data);
setChannels(channelsRes.data);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки данных");
} finally {
setLoading(false);
}
};
const filteredPurchases = purchases.filter((purchase) => {
// Поиск
const matchesSearch =
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 === "completed") {
matchesStatus = !!purchase.actual_date;
} else if (filterStatus === "scheduled") {
matchesStatus = !purchase.actual_date;
} else if (filterStatus === "archived") {
matchesStatus = purchase.is_archived;
}
return matchesSearch && matchesChannel && matchesStatus;
});
// Статистика
const stats = {
total: purchases.length,
completed: purchases.filter((p) => p.actual_date).length,
scheduled: purchases.filter((p) => !p.actual_date).length,
totalCost: purchases.reduce((sum, p) => sum + (p.cost || 0), 0),
totalSubscriptions: purchases.reduce(
(sum, p) => sum + p.subscriptions_count,
0
),
avgCpf:
purchases.length > 0
? purchases.reduce((sum, p) => sum + (p.cpf || 0), 0) / purchases.length
: 0,
};
return (
<>
<DashboardHeader
breadcrumbs={[{ label: "Главная", href: "/" }, { 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>
<Button asChild>
<Link href="/purchases/new">
<Plus className="mr-2 h-4 w-4" />
Создать закуп
</Link>
</Button>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<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" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(stats.total)}
</div>
<p className="text-xs text-muted-foreground mt-1">
{stats.completed} завершено, {stats.scheduled} запланировано
</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(stats.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">
{formatNumber(stats.totalSubscriptions)}
</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">
{formatMetric(stats.avgCpf)}
</div>
<p className="text-xs text-muted-foreground mt-1">
По всем закупам
</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Фильтры</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Поиск по названию..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</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="Статус" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все статусы</SelectItem>
<SelectItem value="completed">Завершено</SelectItem>
<SelectItem value="scheduled">Запланировано</SelectItem>
<SelectItem value="archived">Архив</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">
Найдено: {formatNumber(filteredPurchases.length)}
</Badge>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Список закупов</CardTitle>
<CardDescription>Все рекламные размещения</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : filteredPurchases.length === 0 ? (
<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"
? "Закупы не найдены"
: "Нет закупов"}
</p>
<p className="text-sm text-muted-foreground mt-1">
{searchQuery ||
filterChannel !== "all" ||
filterStatus !== "all"
? "Попробуйте изменить фильтры"
: "Создайте первый закуп"}
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Статус</TableHead>
<TableHead>Целевой канал</TableHead>
<TableHead>Внешний канал</TableHead>
<TableHead>Креатив</TableHead>
<TableHead>Дата</TableHead>
<TableHead className="text-right">Стоимость</TableHead>
<TableHead className="text-right">Подписчики</TableHead>
<TableHead className="text-right">CPF</TableHead>
<TableHead className="text-right">Конверсия</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPurchases.map((purchase) => (
<TableRow key={purchase.id}>
<TableCell>
{purchase.is_archived ? (
<Badge variant="secondary">
<Archive className="h-3 w-3 mr-1" />
Архив
</Badge>
) : purchase.actual_date ? (
<Badge variant="default">
<CheckCircle className="h-3 w-3 mr-1" />
Завершено
</Badge>
) : (
<Badge variant="outline">
<Clock className="h-3 w-3 mr-1" />
Запланировано
</Badge>
)}
</TableCell>
<TableCell className="font-medium">
{purchase.target_channel.title}
</TableCell>
<TableCell>{purchase.external_channel.title}</TableCell>
<TableCell className="max-w-[200px] truncate">
{purchase.creative.name}
</TableCell>
<TableCell>
{formatDate(
purchase.actual_date || purchase.scheduled_date
)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(purchase.cost)}
</TableCell>
<TableCell className="text-right">
{formatNumber(purchase.subscriptions_count)}
</TableCell>
<TableCell className="text-right">
{purchase.cpf ? `${formatMetric(purchase.cpf)}` : "—"}
</TableCell>
<TableCell className="text-right">
{purchase.conversion_rate
? formatPercent(purchase.conversion_rate)
: "—"}
</TableCell>
<TableCell className="text-right">
<Button asChild variant="ghost" size="sm">
<Link href={`/purchases/${purchase.id}`}>
<Eye className="h-4 w-4" />
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</>
);
}