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

501 lines
17 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";
// ============================================================================
// Purchase Detail Page
// ============================================================================
import React, { useEffect, useState } from "react";
import { use } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
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 { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { purchasesApi } from "@/lib/api";
import {
formatNumber,
formatMetric,
formatCurrency,
formatDate,
formatPercent,
formatUsername,
} from "@/lib/utils/format";
import {
ShoppingCart,
ArrowLeft,
Loader2,
AlertCircle,
Archive,
Trash2,
BarChart3,
Users,
TrendingUp,
Eye,
Copy,
ExternalLink as ExternalLinkIcon,
Check,
Clock,
CheckCircle,
} from "lucide-react";
import type { PurchaseDetail } from "@/lib/types/api";
interface PageProps {
params: Promise<{ id: string }>;
}
export default function PurchaseDetailPage({ params }: PageProps) {
const resolvedParams = use(params);
const router = useRouter();
const [purchase, setPurchase] = useState<PurchaseDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
const loadPurchase = async () => {
try {
setLoading(true);
const data = await purchasesApi.get(resolvedParams.id);
setPurchase(data);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки закупа");
} finally {
setLoading(false);
}
};
loadPurchase();
}, [resolvedParams.id]);
const handleArchive = async () => {
if (!purchase) return;
try {
await purchasesApi.update(purchase.id, {
is_archived: !purchase.is_archived,
});
setPurchase({ ...purchase, is_archived: !purchase.is_archived });
} catch (err: any) {
alert(err?.error?.message || "Ошибка обновления закупа");
}
};
const handleDelete = async () => {
if (!purchase) return;
if (!confirm("Удалить закуп?")) return;
try {
await purchasesApi.delete(purchase.id);
router.push("/purchases");
} catch (err: any) {
alert(err?.error?.message || "Ошибка удаления закупа");
}
};
const handleCopyLink = () => {
if (!purchase) return;
navigator.clipboard.writeText(purchase.invite_link);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (loading) {
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Закупы", href: "/purchases" },
{ label: "Загрузка..." },
]}
/>
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
if (error || !purchase) {
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Закупы", href: "/purchases" },
{ label: "Ошибка" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error || "Закуп не найден"}</AlertDescription>
</Alert>
<Button asChild variant="outline">
<Link href="/purchases">
<ArrowLeft className="mr-2 h-4 w-4" />
Вернуться к закупам
</Link>
</Button>
</div>
</>
);
}
const messageText = purchase.creative.text.replace(
"{link}",
purchase.invite_link
);
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: "/" },
{ label: "Закупы", href: "/purchases" },
{ label: `Закуп #${purchase.id.slice(0, 8)}` },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<ShoppingCart className="h-6 w-6" />
<h1 className="text-3xl font-bold tracking-tight">
{purchase.external_channel.title}
</h1>
{purchase.is_archived ? (
<Badge variant="secondary">Архив</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>
)}
</div>
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
<span>
Целевой канал:{" "}
<Link
href={`/channels/${purchase.target_channel.id}`}
className="hover:underline font-medium"
>
{purchase.target_channel.title}
</Link>
</span>
<span></span>
<span>
Креатив:{" "}
<Link
href={`/creatives/${purchase.creative.id}`}
className="hover:underline font-medium"
>
{purchase.creative.name}
</Link>
</span>
<span></span>
<span>
Дата размещения:{" "}
{formatDate(purchase.actual_date || purchase.scheduled_date)}
</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleArchive}>
<Archive className="mr-2 h-4 w-4" />
{purchase.is_archived ? "Разархивировать" : "Архивировать"}
</Button>
<Button variant="destructive" onClick={handleDelete}>
<Trash2 className="mr-2 h-4 w-4" />
Удалить
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<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(purchase.cost)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Подписчики</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(purchase.subscriptions_count)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Просмотры</CardTitle>
<Eye className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{purchase.views_count
? formatNumber(purchase.views_count)
: "—"}
</div>
</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>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{purchase.cpf ? `${formatMetric(purchase.cpf)}` : "—"}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Конверсия</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{purchase.conversion_rate
? formatPercent(purchase.conversion_rate)
: "—"}
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Пригласительная ссылка</CardTitle>
<CardDescription>
Используется для отслеживания подписок
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<code className="flex-1 p-2 bg-muted rounded text-sm break-all">
{purchase.invite_link}
</code>
<Button onClick={handleCopyLink} variant="outline" size="icon">
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
{purchase.post_link && (
<div className="pt-2">
<Label className="text-sm text-muted-foreground">
Ссылка на рекламный пост:
</Label>
<a
href={purchase.post_link}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm hover:underline mt-1"
>
{purchase.post_link}
<ExternalLinkIcon className="h-3 w-3" />
</a>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Рекламное сообщение</CardTitle>
<CardDescription>Текст для размещения</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg border bg-muted/50 p-4">
<p className="text-sm whitespace-pre-wrap">{messageText}</p>
</div>
</CardContent>
</Card>
</div>
{purchase.comment && (
<Card>
<CardHeader>
<CardTitle>Комментарий</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{purchase.comment}
</p>
</CardContent>
</Card>
)}
<Tabs defaultValue="subscriptions" className="w-full">
<TabsList>
<TabsTrigger value="subscriptions">
Подписки ({purchase.subscriptions.length})
</TabsTrigger>
{purchase.views_history && purchase.views_history.length > 0 && (
<TabsTrigger value="views">
История просмотров ({purchase.views_history.length})
</TabsTrigger>
)}
</TabsList>
<TabsContent value="subscriptions" className="mt-4">
<Card>
<CardHeader>
<CardTitle>Подписки</CardTitle>
<CardDescription>
Пользователи, перешедшие по ссылке
</CardDescription>
</CardHeader>
<CardContent>
{purchase.subscriptions.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
Подписок пока нет
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Пользователь</TableHead>
<TableHead>Username</TableHead>
<TableHead>Дата подписки</TableHead>
<TableHead>Статус</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{purchase.subscriptions.map((subscription) => (
<TableRow key={subscription.id}>
<TableCell className="font-medium">
{subscription.user_first_name}{" "}
{subscription.user_last_name}
</TableCell>
<TableCell>
{subscription.user_username
? formatUsername(subscription.user_username)
: "—"}
</TableCell>
<TableCell>
{formatDate(subscription.subscribed_at)}
</TableCell>
<TableCell>
{subscription.is_active ? (
<Badge variant="default">Активен</Badge>
) : (
<Badge variant="secondary">Отписался</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
{purchase.views_history && purchase.views_history.length > 0 && (
<TabsContent value="views" className="mt-4">
<Card>
<CardHeader>
<CardTitle>История просмотров</CardTitle>
<CardDescription>
Динамика просмотров рекламного поста
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Дата</TableHead>
<TableHead className="text-right">Просмотры</TableHead>
<TableHead className="text-right">Прирост</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{purchase.views_history.map((item, index) => {
const prevViews =
index > 0
? purchase.views_history![index - 1].views
: 0;
const growth = item.views - prevViews;
return (
<TableRow key={item.fetched_at}>
<TableCell>{formatDate(item.fetched_at)}</TableCell>
<TableCell className="text-right">
{formatNumber(item.views)}
</TableCell>
<TableCell className="text-right">
{index > 0 && (
<span
className={
growth > 0
? "text-green-600"
: "text-muted-foreground"
}
>
+{formatNumber(growth)}
</span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
)}
</Tabs>
</div>
</>
);
}
function Label({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={className}>{children}</div>;
}