432 lines
14 KiB
TypeScript
432 lines
14 KiB
TypeScript
"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>
|
||
</>
|
||
);
|
||
}
|