Files
2026-02-10 17:15:38 +03:00

272 lines
11 KiB
TypeScript
Raw Permalink 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";
// ============================================================================
// Placements Overview Page - Projects with Placement Statistics
// ============================================================================
import { useEffect, useState, useMemo } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import {
Loader2,
FolderKanban,
ChevronRight,
Users,
Eye,
DollarSign,
Target,
TrendingUp,
CircleDollarSign,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwarePlacementsApi } from "@/lib/demo";
import { placementsApi } from "@/lib/api";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import type { Project, ProjectPlacementsSummary, Placement } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatNumber = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
const formatCurrency = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatCompact = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `${(value / 1000).toFixed(1)}K`;
return value.toString();
};
// ============================================================================
// Component
// ============================================================================
export default function PurchasePlansPage() {
const params = useParams();
const router = useRouter();
const workspaceId = params?.workspaceId as string;
const { projects, isLoadingProjects, currentWorkspace, canViewAnalytics, canViewSubscriptions } = useWorkspace();
const [placementsByProject, setPlacementsByProject] = useState<Record<string, Placement[]>>({});
const [loadingStats, setLoadingStats] = useState(true);
const { isDemoMode } = useWorkspace();
useEffect(() => {
if (projects.length > 0) {
loadAllPlacements();
} else {
setLoadingStats(false);
}
}, [projects, workspaceId]);
const loadAllPlacements = async () => {
setLoadingStats(true);
const placementsMap: Record<string, Placement[]> = {};
for (const project of projects) {
try {
const api = isDemoMode ? demoAwarePlacementsApi : placementsApi;
const placements = await api.getByProject(workspaceId, project.id);
placementsMap[project.id] = placements;
} catch (err) {
console.error(`Failed to load placements for project ${project.id}:`, err);
placementsMap[project.id] = [];
}
}
setPlacementsByProject(placementsMap);
setLoadingStats(false);
};
const calculateProjectSummary = (placements: Placement[]): ProjectPlacementsSummary => {
const total_cost = placements.reduce((sum, p) => sum + (p.details?.cost?.value || 0), 0);
const total_subscriptions = placements.reduce((sum, p) => sum + (p.placement_post?.subscriptions_count || 0), 0);
const total_views = placements.reduce((sum, p) => sum + (p.placement_post?.views_count || 0), 0);
const channelsSet = new Set(placements.map(p => p.channel.id));
return {
total_placements: placements.length,
total_channels: channelsSet.size,
total_cost,
total_subscriptions,
total_views,
avg_cpf: total_subscriptions > 0 ? total_cost / total_subscriptions : null,
avg_cpm: total_views > 0 ? (total_cost / total_views) * 1000 : null,
};
};
const isLoading = isLoadingProjects || loadingStats;
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Размещения" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Размещения</h1>
<p className="text-muted-foreground">
Статистика по всем размещениям ваших проектов
</p>
</div>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : projects.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет проектов</h3>
<p className="text-muted-foreground text-center mb-4">
Добавьте проект через Telegram бота для отслеживания размещений
</p>
<Button asChild>
<Link href={`/dashboard/${workspaceId}/projects`}>
Перейти к проектам
</Link>
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => {
const placements = placementsByProject[project.id] || [];
const summary = calculateProjectSummary(placements);
const avatarUrl = project.username
? `https://t.me/i/userpic/320/${project.username}.jpg`
: null;
return (
<Card key={project.id}
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() =>
router.push(`/dashboard/${workspaceId}/purchase-plans/${project.id}`)
}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={avatarUrl || undefined} alt={project.title} />
<AvatarFallback className="rounded-lg bg-primary/10">
<FolderKanban className="h-5 w-5 text-primary" />
</AvatarFallback>
</Avatar>
<div>
<CardTitle className="text-lg">{project.title}</CardTitle>
{project.username && (
<CardDescription>@{project.username}</CardDescription>
)}
</div>
</div>
<ChevronRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
{summary.total_placements > 0 ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center h-8 w-8 rounded-lg bg-primary/10">
<Target className="h-4 w-4 text-primary" />
</div>
<div>
<div className="text-xs text-muted-foreground">Размещений</div>
<div className="font-semibold">{formatNumber(summary.total_placements)}</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center justify-center h-8 w-8 rounded-lg bg-primary/10">
<Users className="h-4 w-4 text-primary" />
</div>
<div>
<div className="text-xs text-muted-foreground">Каналов</div>
<div className="font-semibold">{formatNumber(summary.total_channels)}</div>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Охват:</span>
<span className="font-medium">
{canViewAnalytics ? formatCompact(summary.total_views) : "—"}
</span>
</div>
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Подписок:</span>
<span className="font-medium">
{canViewSubscriptions ? formatCompact(summary.total_subscriptions) : "—"}
</span>
</div>
<div className="pt-2 border-t space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<CircleDollarSign className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Бюджет</span>
</div>
<span className="font-semibold">{formatCurrency(summary.total_cost)}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">CPF</span>
</div>
<span className="font-medium">
{canViewSubscriptions ? formatCurrency(summary.avg_cpf) : "—"}
</span>
</div>
</div>
</div>
) : (
<div className="text-sm text-muted-foreground">
Нет размещений
</div>
)}
</CardContent>
</Card>
);
})}
</div>
)}
</div>
</>
);
}