Files
tgex-frontend/app/dashboard/[workspaceId]/placements/[placementId]/page.tsx
2025-12-29 10:12:13 +03:00

432 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";
// ============================================================================
// Placement Detail Page
// ============================================================================
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import {
Loader2,
ArrowLeft,
ExternalLink,
Copy,
Check,
Archive,
Eye,
Users,
TrendingUp,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { placementsApi } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import type { Placement } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ru-RU", {
day: "numeric",
month: "long",
year: "numeric",
});
};
const formatDateTime = (dateString: string | null | undefined) => {
if (!dateString) return "—";
return new Date(dateString).toLocaleString("ru-RU", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const formatCurrency = (value: number | null | undefined) => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatNumber = (value: number | null | undefined) => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
// ============================================================================
// Component
// ============================================================================
export default function PlacementDetailPage() {
const params = useParams();
const router = useRouter();
const workspaceId = params?.workspaceId as string;
const placementId = params?.placementId as string;
const { hasPermission } = useWorkspace();
const [placement, setPlacement] = useState<Placement | null>(null);
const [loading, setLoading] = useState(true);
const [copiedLink, setCopiedLink] = useState(false);
const canWrite = hasPermission("placements_write");
useEffect(() => {
loadPlacement();
}, [workspaceId, placementId]);
const loadPlacement = async () => {
try {
setLoading(true);
const data = await placementsApi.get(workspaceId, placementId);
setPlacement(data);
} catch (err) {
console.error("Failed to load placement:", err);
} finally {
setLoading(false);
}
};
const handleArchive = async () => {
try {
await placementsApi.delete(workspaceId, placementId);
router.push(`/dashboard/${workspaceId}/placements`);
} catch (err) {
console.error("Failed to archive:", err);
}
};
const handleCopyLink = () => {
if (placement?.invite_link) {
navigator.clipboard.writeText(placement.invite_link);
setCopiedLink(true);
setTimeout(() => setCopiedLink(false), 2000);
}
};
if (loading) {
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Загрузка..." }]} />
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
if (!placement) {
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Ошибка" }]} />
<div className="flex flex-col items-center justify-center py-12">
<h2 className="text-xl font-semibold mb-2">Размещение не найдено</h2>
<Button asChild>
<Link href={`/dashboard/${workspaceId}/placements`}>
Вернуться к списку
</Link>
</Button>
</div>
</>
);
}
// Calculate metrics
const cps =
placement.cost && placement.subscriptions_count > 0
? placement.cost / placement.subscriptions_count
: null;
const cpm =
placement.cost && placement.views_count
? (placement.cost / placement.views_count) * 1000
: null;
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Размещения", href: `/dashboard/${workspaceId}/placements` },
{ label: placement.placement_channel_title },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/dashboard/${workspaceId}/placements`)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold tracking-tight">
{placement.placement_channel_title}
</h1>
<Badge
variant={
placement.status === "active" ? "default" : "secondary"
}
>
{placement.status === "active" ? "Активно" : "Архив"}
</Badge>
</div>
<p className="text-muted-foreground">
{formatDate(placement.placement_date)}
</p>
</div>
</div>
{canWrite && placement.status === "active" && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline">
<Archive className="h-4 w-4 mr-2" />
В архив
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Архивировать размещение?</AlertDialogTitle>
<AlertDialogDescription>
Размещение будет перемещено в архив. Статистика сохранится.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction onClick={handleArchive}>
Архивировать
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{/* Stats Cards */}
<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>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(placement.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(placement.subscriptions_count)}
</div>
<p className="text-xs text-muted-foreground">
CPS: {cps ? formatCurrency(cps) : "—"}
</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>
<Eye className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(placement.views_count)}
</div>
<p className="text-xs text-muted-foreground">
CPM: {cpm ? formatCurrency(cpm) : "—"}
</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">
{placement.invite_link_type === "approval"
? "С заявками"
: "Публичная"}
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{/* Invite Link */}
<Card>
<CardHeader>
<CardTitle>Пригласительная ссылка</CardTitle>
<CardDescription>
{placement.invite_link_type === "approval"
? "С одобрением — бот отслеживает подписчиков"
: "Публичная ссылка"}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm truncate">
{placement.invite_link}
</code>
<Button variant="outline" size="icon" onClick={handleCopyLink}>
{copiedLink ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<Button variant="outline" size="icon" asChild>
<a
href={placement.invite_link}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
</CardContent>
</Card>
{/* Creative */}
<Card>
<CardHeader>
<CardTitle>Креатив</CardTitle>
<CardDescription>{placement.creative_name}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" asChild>
<Link
href={`/dashboard/${workspaceId}/creatives/${placement.creative_id}`}
>
Просмотреть креатив
</Link>
</Button>
</CardContent>
</Card>
</div>
{/* Details */}
<Card>
<CardHeader>
<CardTitle>Детали размещения</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-muted-foreground">
Проект
</dt>
<dd className="text-sm">{placement.project_channel_title}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Дата размещения
</dt>
<dd className="text-sm">{formatDate(placement.placement_date)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Статус просмотров
</dt>
<dd className="text-sm">
<Badge variant="outline">
{placement.views_availability === "available"
? "Автообновление"
: placement.views_availability === "manual"
? "Ручной ввод"
: placement.views_availability === "unavailable"
? "Недоступно"
: "Не проверено"}
</Badge>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Последнее обновление просмотров
</dt>
<dd className="text-sm">
{formatDateTime(placement.last_views_fetch_at)}
</dd>
</div>
{placement.ad_post_url && (
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-muted-foreground">
Ссылка на пост
</dt>
<dd className="text-sm">
<a
href={placement.ad_post_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{placement.ad_post_url}
<ExternalLink className="h-3 w-3" />
</a>
</dd>
</div>
)}
{placement.comment && (
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-muted-foreground">
Комментарий
</dt>
<dd className="text-sm">{placement.comment}</dd>
</div>
)}
</dl>
</CardContent>
</Card>
</div>
</>
);
}