Files
tgex-frontend/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx
2026-01-21 22:43:54 +03:00

665 lines
26 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";
// ============================================================================
// Placements Detail Page - All placements grouped by channel
// ============================================================================
import { useEffect, useState, useMemo, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import {
Loader2,
Plus,
ArrowLeft,
ChevronDown,
ChevronRight,
Search,
Filter,
Eye,
Users,
ExternalLink,
CircleDollarSign,
TrendingUp,
RefreshCw,
Loader2 as LoaderIcon,
BarChart3,
ArrowUpDown,
X,
} 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 { AddChannelDialog } from "@/components/dialog-add-channel";
import { CreatePlacementsDialog } from "@/components/dialog-create-placements";
import { FiltersModal, PlacementFilters } from "@/components/filters-modal";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type {
Project,
Placement,
PlacementWithStats,
PlacementsGroupedByChannel,
PlacementStatus,
} 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();
};
const formatDate = (dateStr: string | null): string => {
if (!dateStr) return "—";
const date = new Date(dateStr);
return date.toLocaleDateString("ru-RU", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
const PLACEMENT_STATUSES: PlacementStatus[] = [
"Без статуса",
"Написать",
"Ждём ответа",
"Согласование условий",
"Оплатить",
"Оплачено",
"Отмена",
"Не подходит цена",
"Неактуально",
"Не отвечает",
];
const getStatusColor = (status: PlacementStatus): string => {
switch (status) {
case "Оплачено":
return "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:border-emerald-800";
case "Согласование условий":
case "Ждём ответа":
return "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-800";
case "Отмена":
case "Не подходит цена":
case "Неактуально":
case "Не отвечает":
return "bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800";
case "Оплатить":
return "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800";
default:
return "bg-muted text-muted-foreground border-border";
}
};
// ============================================================================
// Components
// ============================================================================
interface ChannelGroupProps {
channel: Placement["channel"];
placements: PlacementWithStats[];
channelNumber: number;
}
function ChannelGroup({ channel, placements, channelNumber }: ChannelGroupProps) {
const [isOpen, setIsOpen] = useState(true);
const totalCost = placements.reduce((sum, p) => sum + (p.details?.cost?.value || 0), 0);
const totalSubscriptions = placements.reduce((sum, p) => sum + (p.placement_post?.subscriptions_count || 0), 0);
const totalViews = placements.reduce((sum, p) => sum + (p.placement_post?.views_count || 0), 0);
const avgCpf = totalSubscriptions > 0 ? totalCost / totalSubscriptions : null;
const avgCpm = totalViews > 0 ? (totalCost / totalViews) * 1000 : null;
if (placements.length === 0) return null;
return (
<div className="mb-3">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-lg cursor-pointer hover:bg-muted transition-colors">
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
!isOpen && "-rotate-90"
)}
/>
<span className="text-xs font-mono text-muted-foreground bg-background px-1.5 py-0.5 rounded w-6 text-center">
{channelNumber}
</span>
<Avatar className="h-8 w-8">
<AvatarImage
src={channel.username ? `https://t.me/i/userpic/160/${channel.username}.jpg` : undefined}
alt={channel.title}
/>
<AvatarFallback className="text-sm">
{channel.title[0]}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="font-medium text-sm">{channel.title}</span>
{channel.username && (
<span className="text-xs text-muted-foreground">@{channel.username}</span>
)}
</div>
{channel.username && (
<div className="flex items-center gap-1 ml-2">
<a
href={`https://telemetr.me/channel/${channel.username}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center h-6 w-6 hover:opacity-75 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<img src="/icons/telemetr.jpg" alt="TgStat" className="h-5 w-5 rounded" />
</a>
<a
href={`https://tgstat.ru/channel/@${channel.username}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center h-6 w-6 hover:opacity-75 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<img src="/icons/tgstat.jpg" alt="TgStat" className="h-5 w-5 rounded" />
</a>
</div>
)}
<Badge variant="outline" className="ml-auto text-xs">
{placements.length}
</Badge>
<div className="flex items-center gap-3 text-xs text-muted-foreground ml-2">
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{formatCompact(channel.subscribers_count)}
</span>
<span className="flex items-center gap-1">
<Eye className="h-3 w-3" />
{formatCompact(totalViews)}
</span>
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-3 font-medium text-muted-foreground w-8"></th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground w-16"></th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground w-28">Статус</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground w-28">Дата</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground w-24">Стоимость</th>
<th className="text-right py-2 px-3 font-medium text-muted-foreground w-20">Охват</th>
<th className="text-right py-2 px-3 font-medium text-muted-foreground w-20">Подписки</th>
<th className="text-right py-2 px-3 font-medium text-muted-foreground w-20">CPF</th>
<th className="text-right py-2 px-3 font-medium text-muted-foreground w-20">CPM</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground w-24">Формат</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground w-32">Комментарий</th>
</tr>
</thead>
<tbody>
{placements.map((placement, idx) => {
const cpf = placement.placement_post?.subscriptions_count
? (placement.details?.cost?.value || 0) / placement.placement_post.subscriptions_count
: null;
const cpm = placement.placement_post?.views_count
? ((placement.details?.cost?.value || 0) / placement.placement_post.views_count) * 1000
: null;
return (
<tr key={placement.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="py-2 px-3"></td>
<td className="py-2 px-3">
<span className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{channelNumber}.{idx + 1}
</span>
</td>
<td className="py-2 px-3">
<span className={cn(
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border",
getStatusColor(placement.status)
)}>
{placement.status}
</span>
</td>
<td className="py-2 px-3 text-muted-foreground">
{formatDate(placement.details?.placement_at || null)}
</td>
<td className="py-2 px-3 font-medium">
{formatCurrency(placement.details?.cost?.value || null)}
</td>
<td className="py-2 px-3 text-right text-muted-foreground">
{formatCompact(placement.placement_post?.views_count || null)}
</td>
<td className="py-2 px-3 text-right text-muted-foreground">
{formatNumber(placement.placement_post?.subscriptions_count || null)}
</td>
<td className="py-2 px-3 text-right font-medium">
{formatCurrency(cpf)}
</td>
<td className="py-2 px-3 text-right font-medium">
{formatCurrency(cpm)}
</td>
<td className="py-2 px-3 text-muted-foreground">
{placement.details?.format || "—"}
</td>
<td className="py-2 px-3 text-muted-foreground max-w-[150px] truncate">
{placement.comment || "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export default function PurchasePlanDetailPage() {
const params = useParams();
const router = useRouter();
const workspaceId = params?.workspaceId as string;
const projectId = params?.projectId as string;
const { projects, hasPermission } = useWorkspace();
const [placements, setPlacements] = useState<Placement[]>([]);
const [loading, setLoading] = useState(true);
const [project, setProject] = useState<Project | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [expandedAll, setExpandedAll] = useState(true);
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState<PlacementFilters>({
channelFilters: {
name: { value: "", condition: "contains" },
username: { value: "", condition: "contains" },
subscribersMin: null,
subscribersMax: null,
},
placementFilters: {
statuses: [],
costMin: null,
costMax: null,
dateFrom: null,
dateTo: null,
hasPlacement: null,
},
sort: null,
});
const { isDemoMode } = useWorkspace();
useEffect(() => {
const proj = projects.find((p) => p.id === projectId);
if (proj) {
setProject(proj);
}
}, [projects, projectId]);
useEffect(() => {
loadPlacements();
}, [workspaceId, projectId]);
const loadPlacements = async () => {
try {
setLoading(true);
const api = isDemoMode ? demoAwarePlacementsApi : placementsApi;
const data = await api.getByProject(workspaceId, projectId);
setPlacements(data);
} catch (err) {
console.error("Failed to load placements:", err);
} finally {
setLoading(false);
}
};
const groupedPlacements = useMemo(() => {
const groups: Record<string, { channel: Placement["channel"]; placements: PlacementWithStats[] }> = {};
placements.forEach((placement) => {
const channelId = placement.channel.id;
if (!groups[channelId]) {
groups[channelId] = {
channel: placement.channel,
placements: [],
};
}
const cpf = placement.placement_post?.subscriptions_count
? (placement.details?.cost?.value || 0) / placement.placement_post.subscriptions_count
: null;
const cpm = placement.placement_post?.views_count
? ((placement.details?.cost?.value || 0) / placement.placement_post.views_count) * 1000
: null;
groups[channelId].placements.push({
...placement,
cpf,
cpm,
});
});
return Object.entries(groups).map(([_, group]) => ({
channel: group.channel,
placements: group.placements,
}));
}, [placements]);
const summary = useMemo(() => {
const totalCost = placements.reduce((sum, p) => sum + (p.details?.cost?.value || 0), 0);
const totalSubscriptions = placements.reduce((sum, p) => sum + (p.placement_post?.subscriptions_count || 0), 0);
const totalViews = placements.reduce((sum, p) => sum + (p.placement_post?.views_count || 0), 0);
return {
totalPlacements: placements.length,
totalChannels: groupedPlacements.length,
totalCost,
totalSubscriptions,
totalViews,
avgCpf: totalSubscriptions > 0 ? totalCost / totalSubscriptions : null,
avgCpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null,
};
}, [placements, groupedPlacements]);
const filteredGroups = useMemo(() => {
let filtered = groupedPlacements.filter((group) => {
// Channel filters
const nameFilter = filters.channelFilters.name;
const channelNameMatch = !nameFilter.value ||
(nameFilter.condition === "contains"
? group.channel.title.toLowerCase().includes(nameFilter.value.toLowerCase())
: nameFilter.condition === "not_contains"
? !group.channel.title.toLowerCase().includes(nameFilter.value.toLowerCase())
: group.channel.title.toLowerCase() === nameFilter.value.toLowerCase());
const usernameFilter = filters.channelFilters.username;
const channelUsernameMatch = !usernameFilter.value ||
(usernameFilter.condition === "contains"
? group.channel.username?.toLowerCase().includes(usernameFilter.value.toLowerCase())
: usernameFilter.condition === "not_contains"
? !group.channel.username?.toLowerCase().includes(usernameFilter.value.toLowerCase())
: group.channel.username?.toLowerCase() === usernameFilter.value.toLowerCase());
const subscribersMinMatch = filters.channelFilters.subscribersMin === null ||
(group.channel.subscribers_count || 0) >= filters.channelFilters.subscribersMin;
const subscribersMaxMatch = filters.channelFilters.subscribersMax === null ||
(group.channel.subscribers_count || 0) <= filters.channelFilters.subscribersMax;
// Placement filters
const placementsWithStatus = filters.placementFilters.statuses.length === 0 ||
group.placements.some((p) => filters.placementFilters.statuses.includes(p.status));
const costMinMatch = filters.placementFilters.costMin === null ||
group.placements.some((p) => (p.details?.cost?.value || 0) >= filters.placementFilters.costMin!);
const costMaxMatch = filters.placementFilters.costMax === null ||
group.placements.some((p) => (p.details?.cost?.value || 0) <= filters.placementFilters.costMax!);
let dateMatch = true;
if (filters.placementFilters.dateFrom || filters.placementFilters.dateTo) {
dateMatch = group.placements.some((p) => {
if (!p.details?.placement_at) return false;
const placementDate = new Date(p.details.placement_at);
const fromMatch = !filters.placementFilters.dateFrom ||
placementDate >= new Date(filters.placementFilters.dateFrom);
const toMatch = !filters.placementFilters.dateTo ||
placementDate <= new Date(filters.placementFilters.dateTo);
return fromMatch && toMatch;
});
}
return channelNameMatch && channelUsernameMatch && subscribersMinMatch &&
subscribersMaxMatch && placementsWithStatus && costMinMatch && costMaxMatch && dateMatch;
});
// Apply sorting
if (filters.sort) {
filtered = [...filtered].sort((a, b) => {
let aValue: number | string = 0;
let bValue: number | string = 0;
switch (filters.sort!.field) {
case "title":
aValue = a.channel.title.toLowerCase();
bValue = b.channel.title.toLowerCase();
break;
case "subscribers":
aValue = a.channel.subscribers_count || 0;
bValue = b.channel.subscribers_count || 0;
break;
case "cost":
aValue = Math.max(...a.placements.map((p) => p.details?.cost?.value || 0));
bValue = Math.max(...b.placements.map((p) => p.details?.cost?.value || 0));
break;
case "date":
aValue = Math.max(
...a.placements.map((p) => p.details?.placement_at ? new Date(p.details.placement_at).getTime() : 0)
);
bValue = Math.max(
...b.placements.map((p) => p.details?.placement_at ? new Date(p.details.placement_at).getTime() : 0)
);
break;
case "cpm":
aValue = a.placements[0]?.cpm || 0;
bValue = b.placements[0]?.cpm || 0;
break;
case "cpf":
aValue = a.placements[0]?.cpf || 0;
bValue = b.placements[0]?.cpf || 0;
break;
}
if (typeof aValue === "string") {
return filters.sort!.direction === "asc"
? aValue.localeCompare(bValue as string)
: (bValue as string).localeCompare(aValue);
}
return filters.sort!.direction === "asc"
? (aValue as number) - (bValue as number)
: (bValue as number) - (aValue as number);
});
}
return filtered;
}, [groupedPlacements, filters]);
const canWrite = hasPermission("placements_write");
const hasActiveFilters = filters.channelFilters.name ||
filters.channelFilters.username ||
filters.channelFilters.subscribersMin !== null ||
filters.channelFilters.subscribersMax !== null ||
filters.placementFilters.statuses.length > 0 ||
filters.placementFilters.costMin !== null ||
filters.placementFilters.costMax !== null ||
filters.placementFilters.dateFrom ||
filters.placementFilters.dateTo ||
filters.sort;
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>
</>
);
}
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Размещения", href: `/dashboard/${workspaceId}/purchase-plans` },
{ label: project?.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}/purchase-plans`)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight">
План закупов проекта «{project?.title || "Проект"}»
</h1>
<p className="text-muted-foreground">
Список каналов, в которых планируем размещения для проекта
</p>
</div>
</div>
<div className="flex items-center gap-2">
<AddChannelDialog
workspaceId={workspaceId}
projectId={projectId}
existingPlacements={placements}
onSuccess={loadPlacements}
>
<Button variant="outline" disabled={isDemoMode}>
<Plus className="h-4 w-4 mr-2" />
Добавить канал
</Button>
</AddChannelDialog>
<CreatePlacementsDialog
workspaceId={workspaceId}
projectId={projectId}
existingPlacements={placements}
onSuccess={loadPlacements}
>
<Button disabled={isDemoMode}>
<Plus className="h-4 w-4 mr-2" />
Создать размещения
</Button>
</CreatePlacementsDialog>
</div>
</div>
{/* Filters */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Поиск канала..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button
variant={hasActiveFilters ? "default" : "outline"}
size="sm"
onClick={() => setFiltersOpen(true)}
>
<Filter className="h-4 w-4 mr-1" />
Фильтры
{hasActiveFilters && (
<span className="ml-1 px-1.5 py-0.5 text-xs bg-primary-foreground/20 rounded">
</span>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setExpandedAll(!expandedAll)}
>
{expandedAll ? "Свернуть все" : "Развернуть все"}
</Button>
</div>
{/* Placements List */}
{filteredGroups.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<TrendingUp 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">
{placements.length === 0
? "Добавьте первое размещение для этого проекта"
: "Попробуйте изменить параметры поиска"}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-1">
{filteredGroups.map((group, idx) => (
<ChannelGroup
key={group.channel.id}
channel={group.channel}
placements={group.placements}
channelNumber={idx + 1}
/>
))}
</div>
)}
</div>
<FiltersModal
open={filtersOpen}
onOpenChange={setFiltersOpen}
onApply={setFilters}
existingPlacements={placements}
initialFilters={filters}
/>
</>
);
}