Files
tgex-frontend/app/(dashboard)/purchases/[id]/page.tsx
2025-11-19 12:56:04 +03:00

401 lines
13 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 { 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 { Placement } 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<Placement | 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, {
status: purchase.status === "active" ? "archived" : "active",
});
setPurchase({
...purchase,
status: purchase.status === "active" ? "archived" : "active",
});
} 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>
</>
);
}
// Remove message text generation as we don't have creative.text in Placement
const messageText = `Текст сообщения с ссылкой: ${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.status === "archived" ? (
<Badge variant="secondary">Архив</Badge>
) : (
<Badge variant="default">
<CheckCircle 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`}
className="hover:underline font-medium"
>
{purchase.target_channel_title}
</Link>
</span>
<span></span>
<span>
Креатив:{" "}
<Link
href={`/creatives`}
className="hover:underline font-medium"
>
{purchase.creative_name}
</Link>
</span>
<span></span>
<span>
Дата размещения: {formatDate(purchase.placement_date)}
</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleArchive}>
<Archive className="mr-2 h-4 w-4" />
{purchase.status === "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">
Статус просмотров
</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-lg font-medium">
{purchase.views_availability === "available"
? "Доступны"
: purchase.views_availability === "manual"
? "Вручную"
: purchase.views_availability === "unavailable"
? "Недоступны"
: "Неизвестно"}
</div>
<p className="text-xs text-muted-foreground mt-1">
{purchase.last_views_fetch_at
? `Обновлено: ${formatDate(purchase.last_views_fetch_at)}`
: "Еще не обновлялись"}
</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>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-lg font-medium">
{purchase.invite_link_type === "approval"
? "С одобрением"
: "Публичная"}
</div>
<p className="text-xs text-muted-foreground mt-1">
{purchase.invite_link_type === "approval"
? "Отслеживание подписчиков"
: "Без отслеживания"}
</p>
</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.ad_post_url && (
<div className="pt-2">
<Label className="text-sm text-muted-foreground">
Ссылка на рекламный пост:
</Label>
<a
href={purchase.ad_post_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm hover:underline mt-1"
>
{purchase.ad_post_url}
<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>
)}
<Card>
<CardHeader>
<CardTitle>Заметка</CardTitle>
<CardDescription>
Детальная информация о размещении доступна через раздел "Подписки"
в главном меню
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Для просмотра списка подписчиков и истории просмотров используйте
соответствующие разделы в главном меню.
</p>
</CardContent>
</Card>
</div>
</>
);
}
function Label({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={className}>{children}</div>;
}