feat: all project setup

This commit is contained in:
ivannoskov
2025-11-10 12:07:24 +03:00
parent fbfd7719bb
commit b0a9934220
99 changed files with 19597 additions and 0 deletions

View File

@@ -0,0 +1,381 @@
"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>
</>
);
}