351 lines
13 KiB
TypeScript
351 lines
13 KiB
TypeScript
"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.placements);
|
||
setChannels(channelsRes.target_channels);
|
||
} 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 === "archived") {
|
||
matchesStatus = purchase.status === "archived";
|
||
} else if (filterStatus === "active") {
|
||
matchesStatus = purchase.status === "active";
|
||
}
|
||
|
||
return matchesSearch && matchesChannel && matchesStatus;
|
||
});
|
||
|
||
// Статистика
|
||
const stats = {
|
||
total: purchases.length,
|
||
active: purchases.filter((p) => p.status === "active").length,
|
||
archived: purchases.filter((p) => p.status === "archived").length,
|
||
totalCost: purchases.reduce((sum, p) => sum + (p.cost || 0), 0),
|
||
totalSubscriptions: purchases.reduce(
|
||
(sum, p) => sum + p.subscriptions_count,
|
||
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.active} активных, {stats.archived} в архиве
|
||
</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>
|
||
</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">Просмотры</TableHead>
|
||
<TableHead></TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{filteredPurchases.map((purchase) => (
|
||
<TableRow key={purchase.id}>
|
||
<TableCell>
|
||
{purchase.status === "archived" ? (
|
||
<Badge variant="secondary">
|
||
<Archive className="h-3 w-3 mr-1" />
|
||
Архив
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="default">
|
||
<CheckCircle 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.placement_date)}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
{formatCurrency(purchase.cost)}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
{formatNumber(purchase.subscriptions_count)}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
{purchase.views_count
|
||
? formatNumber(purchase.views_count)
|
||
: "—"}
|
||
</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>
|
||
</>
|
||
);
|
||
}
|