feat: global ui & functional update

This commit is contained in:
ivannoskov
2026-01-21 22:43:54 +03:00
parent 8958d89d12
commit 619fd5c1ef
32 changed files with 3803 additions and 1325 deletions

View File

@@ -13,7 +13,6 @@ import {
ExternalLink,
Copy,
Check,
Archive,
Eye,
Users,
TrendingUp,
@@ -30,17 +29,6 @@ import {
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";
// ============================================================================
@@ -114,15 +102,6 @@ export default function PlacementDetailPage() {
}
};
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);
@@ -159,14 +138,11 @@ export default function PlacementDetailPage() {
}
// 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;
const cost = placement.details?.cost?.value;
const subscriptionsCount = placement.placement_post?.subscriptions_count ?? 0;
const viewsCount = placement.placement_post?.views_count ?? 0;
const cps = cost && subscriptionsCount > 0 ? cost / subscriptionsCount : null;
const cpm = cost && viewsCount > 0 ? (cost / viewsCount) * 1000 : null;
return (
<>
@@ -174,7 +150,7 @@ export default function PlacementDetailPage() {
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Размещения", href: `/dashboard/${workspaceId}/placements` },
{ label: placement.placement_channel_title },
{ label: placement.channel.title },
]}
/>
@@ -192,46 +168,21 @@ export default function PlacementDetailPage() {
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold tracking-tight">
{placement.placement_channel_title}
{placement.channel.title}
</h1>
<Badge
variant={
placement.status === "active" ? "default" : "secondary"
placement.status === "Оплачено" ? "default" : "secondary"
}
>
{placement.status === "active" ? "Активно" : "Архив"}
{placement.status === "Оплачено" ? "Активно" : placement.status}
</Badge>
</div>
<p className="text-muted-foreground">
{formatDate(placement.placement_date)}
{placement.details?.placement_at ? formatDate(placement.details.placement_at) : "—"}
</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 */}
@@ -241,11 +192,11 @@ export default function PlacementDetailPage() {
<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>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(cost)}
</div>
</CardContent>
</Card>
<Card>
@@ -253,14 +204,14 @@ export default function PlacementDetailPage() {
<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>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(subscriptionsCount)}
</div>
<p className="text-xs text-muted-foreground">
CPS: {cps ? formatCurrency(cps) : "—"}
</p>
</CardContent>
</Card>
<Card>
@@ -268,14 +219,14 @@ export default function PlacementDetailPage() {
<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>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(viewsCount)}
</div>
<p className="text-xs text-muted-foreground">
CPM: {cpm ? formatCurrency(cpm) : "—"}
</p>
</CardContent>
</Card>
<Card>
@@ -308,24 +259,31 @@ export default function PlacementDetailPage() {
<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}
{placement.invite_link || "—"}
</code>
<Button variant="outline" size="icon" onClick={handleCopyLink}>
<Button
variant="outline"
size="icon"
onClick={handleCopyLink}
disabled={!placement.invite_link}
>
{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>
{placement.invite_link && (
<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>
@@ -334,16 +292,18 @@ export default function PlacementDetailPage() {
<Card>
<CardHeader>
<CardTitle>Креатив</CardTitle>
<CardDescription>{placement.creative_name}</CardDescription>
<CardDescription>ID: {placement.creative_id}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" asChild>
<Link
href={`/dashboard/${workspaceId}/creatives/${placement.creative_id}`}
>
Просмотреть креатив
</Link>
</Button>
{placement.creative_id && (
<Button variant="outline" asChild>
<Link
href={`/dashboard/${workspaceId}/creatives/${placement.creative_id}`}
>
Просмотреть креатив
</Link>
</Button>
)}
</CardContent>
</Card>
</div>
@@ -357,57 +317,45 @@ export default function PlacementDetailPage() {
<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>
<dd className="text-sm">{placement.channel.title}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Дата размещения
</dt>
<dd className="text-sm">{formatDate(placement.placement_date)}</dd>
<dd className="text-sm">{placement.details?.placement_at ? formatDate(placement.details.placement_at) : "—"}</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>
<dd className="text-sm">{placement.details?.payment_at ? formatDate(placement.details.payment_at) : "—"}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Последнее обновление просмотров
Формат
</dt>
<dd className="text-sm">
{formatDateTime(placement.last_views_fetch_at)}
</dd>
<dd className="text-sm">{placement.details?.format || "—"}</dd>
</div>
{placement.ad_post_url && (
{placement.placement_post?.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}
href={placement.placement_post.post.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{placement.ad_post_url}
{placement.placement_post.post.url}
<ExternalLink className="h-3 w-3" />
</a>
</dd>