diff --git a/app/dashboard/[workspaceId]/analytics/placements/page.tsx b/app/dashboard/[workspaceId]/analytics/placements/page.tsx new file mode 100644 index 0000000..c97037b --- /dev/null +++ b/app/dashboard/[workspaceId]/analytics/placements/page.tsx @@ -0,0 +1,517 @@ +"use client"; + +// ============================================================================ +// All Placements Analytics Page +// ============================================================================ + +import { useEffect, useState, useMemo, useCallback } from "react"; +import { useParams } from "next/navigation"; +import { Loader2, ArrowUpDown, ArrowUpRight, Calendar, Filter, X } from "lucide-react"; +import { format } from "date-fns"; +import { ru } from "date-fns/locale"; +import { DashboardHeader } from "@/components/layout/dashboard-header"; +import { useWorkspace } from "@/components/providers/workspace-provider"; +import { analyticsApi, channelsApi } from "@/lib/api"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Calendar as CalendarComponent } from "@/components/ui/calendar"; +import { cn } from "@/lib/utils"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { PlacementAnalyticsItem, Channel } from "@/lib/types/api"; + +// ============================================================================ +// Types +// ============================================================================ + +type SortField = "placement_date" | "cost" | "subscriptions_count" | "views_count" | "cpf" | "cpm"; +type SortDirection = "asc" | "desc"; + +interface SortConfig { + field: SortField; + direction: SortDirection; +} + +interface DateRange { + from: Date | undefined; + to?: Date | undefined; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +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); +}; + +const formatDate = (dateStr: string | undefined) => { + if (!dateStr) return "—"; + try { + return format(new Date(dateStr), "d MMM yyyy, HH:mm", { locale: ru }); + } catch { + return "—"; + } +}; + +// ============================================================================ +// Main Component +// ============================================================================ + +export default function PlacementsAnalyticsPage() { + const params = useParams(); + const workspaceId = params?.workspaceId as string; + const { projects } = useWorkspace(); + + const [placements, setPlacements] = useState([]); + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(true); + + // Filters + const [selectedProjectId, setSelectedProjectId] = useState("all"); + const [selectedChannelId, setSelectedChannelId] = useState("all"); + const [selectedCreativeId, setSelectedCreativeId] = useState("all"); + const [dateRange, setDateRange] = useState({ from: undefined, to: undefined }); + + // Sorting + const [sortConfig, setSortConfig] = useState({ + field: "placement_date", + direction: "desc", + }); + + const loadChannels = useCallback(async () => { + try { + const response = await channelsApi.list({ size: 1000 }); + setChannels(response.items); + } catch (err) { + console.error("Failed to load channels:", err); + setChannels([]); + } + }, []); + + const loadPlacements = useCallback(async () => { + try { + setLoading(true); + const response = await analyticsApi.placements(workspaceId, { + project_id: selectedProjectId !== "all" ? selectedProjectId : undefined, + placement_channel_id: selectedChannelId !== "all" ? selectedChannelId : undefined, + creative_id: selectedCreativeId !== "all" ? selectedCreativeId : undefined, + date_from: dateRange.from ? dateRange.from.toISOString() : undefined, + date_to: dateRange.to ? dateRange.to.toISOString() : undefined, + }); + setPlacements(response.items); + } catch (err) { + console.error("Failed to load placements:", err); + setPlacements([]); + } finally { + setLoading(false); + } + }, [workspaceId, selectedProjectId, selectedChannelId, selectedCreativeId, dateRange]); + + // Load data + useEffect(() => { + loadChannels(); + loadPlacements(); + }, [workspaceId, selectedProjectId, selectedChannelId, selectedCreativeId, dateRange, loadPlacements, loadChannels]); + + // Get unique creatives from placements + const creatives = useMemo(() => { + const creativeMap = new Map(); + placements.forEach((p) => { + if (p.creative_id && p.creative_name) { + creativeMap.set(p.creative_id, p.creative_name); + } + }); + return Array.from(creativeMap.entries()).map(([id, name]) => ({ id, name })); + }, [placements]); + + // Sort placements + const sortedPlacements = useMemo(() => { + const sorted = [...placements].sort((a, b) => { + let aVal: number | string | null | undefined = a[sortConfig.field]; + let bVal: number | string | null | undefined = b[sortConfig.field]; + + if (sortConfig.field === "placement_date") { + aVal = aVal ? new Date(aVal as string).getTime() : 0; + bVal = bVal ? new Date(bVal as string).getTime() : 0; + } + + if (aVal === null || aVal === undefined) aVal = sortConfig.direction === "asc" ? Infinity : -Infinity; + if (bVal === null || bVal === undefined) bVal = sortConfig.direction === "asc" ? Infinity : -Infinity; + + if (sortConfig.direction === "asc") { + return aVal > bVal ? 1 : -1; + } else { + return aVal < bVal ? 1 : -1; + } + }); + return sorted; + }, [placements, sortConfig]); + + // Calculate totals + const totals = useMemo(() => { + return { + cost: placements.reduce((sum, p) => sum + (p.cost || 0), 0), + subscriptions_count: placements.reduce((sum, p) => sum + p.subscriptions_count, 0), + views_count: placements.reduce((sum, p) => sum + (p.views_count || 0), 0), + cpf: placements.length > 0 + ? placements.reduce((sum, p) => sum + (p.cpf || 0), 0) / placements.length + : null, + cpm: placements.length > 0 + ? placements.reduce((sum, p) => sum + (p.cpm || 0), 0) / placements.length + : null, + }; + }, [placements]); + + // Handle sort click + const handleSort = (field: SortField) => { + setSortConfig((prev) => ({ + field, + direction: prev.field === field && prev.direction === "desc" ? "asc" : "desc", + })); + }; + + // Reset filters + const resetFilters = () => { + setSelectedProjectId("all"); + setSelectedChannelId("all"); + setSelectedCreativeId("all"); + setDateRange({ from: undefined, to: undefined }); + }; + + // Has active filters + const hasActiveFilters = selectedProjectId !== "all" || selectedChannelId !== "all" || selectedCreativeId !== "all" || dateRange.from || dateRange.to; + + return ( + <> + + +
+
+

Все размещения

+

+ Сводная таблица по всем завершённым размещениям +

+
+ + {/* Stats Cards */} +
+ + + Всего размещений + {formatNumber(placements.length)} + + + + + Сумма расходов + {formatCurrency(totals.cost)} + + + + + Подписчиков + {formatNumber(totals.subscriptions_count)} + + + + + Просмотров + {formatNumber(totals.views_count)} + + +
+ + {/* Filters */} + + +
+ Фильтры + {hasActiveFilters && ( + + )} +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + + + + setDateRange({ from: range?.from, to: range?.to })} + initialFocus + numberOfMonths={2} + locale={ru} + /> + + +
+ +
+ +
+
+
+
+ + {/* Table */} + + + {loading ? ( +
+ +
+ ) : placements.length === 0 ? ( +
+

Нет завершённых размещений

+
+ ) : ( +
+ + + + handleSort("placement_date")} + > +
+ Дата + +
+
+ handleSort("cost")} + > +
+ Стоимость + +
+
+ Канал размещения + Проект + Креатив + handleSort("subscriptions_count")} + > +
+ Подписчики + +
+
+ handleSort("views_count")} + > +
+ Просмотры + +
+
+ handleSort("cpf")} + > +
+ CPF + +
+
+ handleSort("cpm")} + > +
+ CPM + +
+
+ Пост +
+
+ + {sortedPlacements.map((placement) => ( + + + {formatDate(placement.placement_date)} + + + {formatCurrency(placement.cost)} + + + {placement.placement_channel_title} + + + {placement.project_channel_title} + + + {placement.creative_name} + + + {formatNumber(placement.subscriptions_count)} + + + {formatNumber(placement.views_count)} + + + {formatCurrency(placement.cpf)} + + + {formatCurrency(placement.cpm)} + + + {placement.post_url ? ( + + + + ) : ( + + )} + + + ))} + {/* Totals Row */} + + ИТОГО + {formatCurrency(totals.cost)} + + {formatNumber(totals.subscriptions_count)} + {formatNumber(totals.views_count)} + {formatCurrency(totals.cpf)} + {formatCurrency(totals.cpm)} + + + +
+
+ )} +
+
+
+ + ); +} diff --git a/app/dashboard/[workspaceId]/placements/[placementId]/page.tsx b/app/dashboard/[workspaceId]/placements/[placementId]/page.tsx index 4792987..73ee635 100644 --- a/app/dashboard/[workspaceId]/placements/[placementId]/page.tsx +++ b/app/dashboard/[workspaceId]/placements/[placementId]/page.tsx @@ -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() {

- {placement.placement_channel_title} + {placement.channel.title}

- {placement.status === "active" ? "Активно" : "Архив"} + {placement.status === "Оплачено" ? "Активно" : placement.status}

- {formatDate(placement.placement_date)} + {placement.details?.placement_at ? formatDate(placement.details.placement_at) : "—"}

- - {canWrite && placement.status === "active" && ( - - - - - - - Архивировать размещение? - - Размещение будет перемещено в архив. Статистика сохранится. - - - - Отмена - - Архивировать - - - - - )} {/* Stats Cards */} @@ -241,11 +192,11 @@ export default function PlacementDetailPage() { Стоимость - -
- {formatCurrency(placement.cost)} -
-
+ +
+ {formatCurrency(cost)} +
+
@@ -253,14 +204,14 @@ export default function PlacementDetailPage() { Подписчиков - -
- {formatNumber(placement.subscriptions_count)} -
-

- CPS: {cps ? formatCurrency(cps) : "—"} -

-
+ +
+ {formatNumber(subscriptionsCount)} +
+

+ CPS: {cps ? formatCurrency(cps) : "—"} +

+
@@ -268,14 +219,14 @@ export default function PlacementDetailPage() { Просмотров - -
- {formatNumber(placement.views_count)} -
-

- CPM: {cpm ? formatCurrency(cpm) : "—"} -

-
+ +
+ {formatNumber(viewsCount)} +
+

+ CPM: {cpm ? formatCurrency(cpm) : "—"} +

+
@@ -308,24 +259,31 @@ export default function PlacementDetailPage() {
- {placement.invite_link} + {placement.invite_link || "—"} - - + {placement.invite_link && ( + + )}
@@ -334,16 +292,18 @@ export default function PlacementDetailPage() { Креатив - {placement.creative_name} + ID: {placement.creative_id} - + {placement.creative_id && ( + + )} @@ -357,57 +317,45 @@ export default function PlacementDetailPage() {
- Проект + Канал размещения
-
{placement.project_channel_title}
+
{placement.channel.title}
Дата размещения
-
{formatDate(placement.placement_date)}
+
{placement.details?.placement_at ? formatDate(placement.details.placement_at) : "—"}
- Статус просмотров + Дата оплаты
-
- - {placement.views_availability === "available" - ? "Автообновление" - : placement.views_availability === "manual" - ? "Ручной ввод" - : placement.views_availability === "unavailable" - ? "Недоступно" - : "Не проверено"} - -
+
{placement.details?.payment_at ? formatDate(placement.details.payment_at) : "—"}
- Последнее обновление просмотров + Формат
-
- {formatDateTime(placement.last_views_fetch_at)} -
+
{placement.details?.format || "—"}
- {placement.ad_post_url && ( + {placement.placement_post?.post?.url && (
Ссылка на пост
- {placement.ad_post_url} + {placement.placement_post.post.url}
diff --git a/app/dashboard/[workspaceId]/placements/page.tsx b/app/dashboard/[workspaceId]/placements/page.tsx index d012005..281e091 100644 --- a/app/dashboard/[workspaceId]/placements/page.tsx +++ b/app/dashboard/[workspaceId]/placements/page.tsx @@ -157,17 +157,17 @@ const exportToCSV = (placements: Placement[], filename: string) => { ]; const rows = placements.map((p) => [ - p.placement_channel_title, + p.channel.title, "", - p.creative_name, - new Date(p.placement_date).toISOString().split("T")[0], - p.cost?.toString() || "", - p.subscriptions_count.toString(), - p.views_count?.toString() || "", - calculateCPS(p.cost, p.subscriptions_count)?.toFixed(2) || "", - calculateCPM(p.cost, p.views_count)?.toFixed(2) || "", + "—", // creative_name not available in new structure + p.details?.placement_at ? new Date(p.details.placement_at).toISOString().split("T")[0] : "", + p.details?.cost?.value?.toString() || "", + p.placement_post?.subscriptions_count.toString() || "0", + p.placement_post?.views_count?.toString() || "", + calculateCPS(p.details?.cost?.value ?? null, p.placement_post?.subscriptions_count ?? 0)?.toFixed(2) || "", + calculateCPM(p.details?.cost?.value ?? null, p.placement_post?.views_count ?? null)?.toFixed(2) || "", p.status, - p.ad_post_url || "", + p.placement_post?.post?.url || "", p.invite_link, ]); @@ -192,15 +192,15 @@ const exportToExcel = (placements: Placement[], filename: string) => { ]; const rows = placements.map((p) => [ - p.placement_channel_title, + p.channel.title, "", - p.creative_name, - new Date(p.placement_date).toISOString().split("T")[0], - p.cost?.toString() || "", - p.subscriptions_count.toString(), - p.views_count?.toString() || "", - calculateCPS(p.cost, p.subscriptions_count)?.toFixed(2) || "", - calculateCPM(p.cost, p.views_count)?.toFixed(2) || "", + "—", + p.details?.placement_at ? new Date(p.details.placement_at).toISOString().split("T")[0] : "", + p.details?.cost?.value?.toString() || "", + p.placement_post?.subscriptions_count.toString() || "0", + p.placement_post?.views_count?.toString() || "", + calculateCPS(p.details?.cost?.value ?? null, p.placement_post?.subscriptions_count ?? 0)?.toFixed(2) || "", + calculateCPM(p.details?.cost?.value ?? null, p.placement_post?.views_count ?? null)?.toFixed(2) || "", p.status, ]); @@ -213,18 +213,21 @@ const exportToExcel = (placements: Placement[], filename: string) => { const exportToTxt = (placements: Placement[], filename: string) => { const lines = placements.map((p) => { - const cps = calculateCPS(p.cost, p.subscriptions_count); - const cpm = calculateCPM(p.cost, p.views_count); + const cost = p.details?.cost?.value ?? null; + const subscriptions = p.placement_post?.subscriptions_count ?? 0; + const views = p.placement_post?.views_count ?? null; + const cps = calculateCPS(cost, subscriptions); + const cpm = calculateCPM(cost, views); return [ - `Канал: ${p.placement_channel_title}`, - `Креатив: ${p.creative_name}`, - `Дата: ${formatDate(p.placement_date)}`, - `Стоимость: ${formatCurrency(p.cost)}`, - `Подписки: ${p.subscriptions_count}`, - `Просмотры: ${p.views_count || "—"}`, + `Канал: ${p.channel.title}`, + `Креатив: —`, + `Дата: ${p.details?.placement_at ? formatDate(p.details.placement_at) : "—"}`, + `Стоимость: ${formatCurrency(cost)}`, + `Подписки: ${p.placement_post?.subscriptions_count ?? "—"}`, + `Просмотры: ${p.placement_post?.views_count || "—"}`, `CPS: ${cps ? formatCurrency(cps) : "—"}`, `CPM: ${cpm ? formatCurrency(cpm) : "—"}`, - `Ссылка: ${p.invite_link}`, + `Ссылка: ${p.invite_link || "—"}`, "---", ].join("\n"); }); @@ -320,8 +323,8 @@ export default function PlacementsPage() { return placements .filter( (p) => - p.placement_channel_title.toLowerCase().includes(search.toLowerCase()) || - p.creative_name.toLowerCase().includes(search.toLowerCase()) + p.channel.title.toLowerCase().includes(search.toLowerCase()) || + (p.creative_id && p.creative_id.toString().toLowerCase().includes(search.toLowerCase())) ) .sort((a, b) => { if (!sortField || !sortOrder) return 0; @@ -329,30 +332,39 @@ export default function PlacementsPage() { let aVal: number | string = 0; let bVal: number | string = 0; + const aCost = a.details?.cost?.value ?? null; + const bCost = b.details?.cost?.value ?? null; + const aSubs = a.placement_post?.subscriptions_count ?? 0; + const bSubs = b.placement_post?.subscriptions_count ?? 0; + const aViews = a.placement_post?.views_count ?? null; + const bViews = b.placement_post?.views_count ?? null; + switch (sortField) { case "date": + const aDate = a.details?.placement_at || ""; + const bDate = b.details?.placement_at || ""; return sortOrder === "asc" - ? new Date(a.placement_date).getTime() - new Date(b.placement_date).getTime() - : new Date(b.placement_date).getTime() - new Date(a.placement_date).getTime(); + ? aDate.localeCompare(bDate) + : bDate.localeCompare(aDate); case "cost": - aVal = a.cost ?? 0; - bVal = b.cost ?? 0; + aVal = aCost ?? 0; + bVal = bCost ?? 0; break; case "subscriptions": - aVal = a.subscriptions_count; - bVal = b.subscriptions_count; + aVal = aSubs; + bVal = bSubs; break; case "views": - aVal = a.views_count ?? 0; - bVal = b.views_count ?? 0; + aVal = aViews ?? 0; + bVal = bViews ?? 0; break; case "cps": - aVal = calculateCPS(a.cost, a.subscriptions_count) ?? 0; - bVal = calculateCPS(b.cost, b.subscriptions_count) ?? 0; + aVal = calculateCPS(aCost, aSubs) ?? 0; + bVal = calculateCPS(bCost, bSubs) ?? 0; break; case "cpm": - aVal = calculateCPM(a.cost, a.views_count) ?? 0; - bVal = calculateCPM(b.cost, b.views_count) ?? 0; + aVal = calculateCPM(aCost, aViews) ?? 0; + bVal = calculateCPM(bCost, bViews) ?? 0; break; case "status": aVal = a.status; @@ -370,14 +382,14 @@ export default function PlacementsPage() { // Calculate totals with aggregation functions const totals = useMemo(() => { - const costs = filteredPlacements.map((p) => p.cost).filter((c): c is number => c !== null); - const subs = filteredPlacements.map((p) => p.subscriptions_count); - const views = filteredPlacements.map((p) => p.views_count).filter((v): v is number => v !== null); + const costs = filteredPlacements.map((p) => p.details?.cost?.value).filter((c): c is number => c !== null); + const subs = filteredPlacements.map((p) => p.placement_post?.subscriptions_count ?? 0); + const views = filteredPlacements.map((p) => p.placement_post?.views_count).filter((v): v is number => v !== null); const cpsValues = filteredPlacements - .map((p) => calculateCPS(p.cost, p.subscriptions_count)) + .map((p) => calculateCPS(p.details?.cost?.value ?? null, p.placement_post?.subscriptions_count ?? 0)) .filter((c): c is number => c !== null); const cpmValues = filteredPlacements - .map((p) => calculateCPM(p.cost, p.views_count)) + .map((p) => calculateCPM(p.details?.cost?.value ?? null, p.placement_post?.views_count ?? null)) .filter((c): c is number => c !== null); return { @@ -639,28 +651,31 @@ export default function PlacementsPage() { {filteredPlacements.map((placement) => { - const cps = calculateCPS(placement.cost, placement.subscriptions_count); - const cpm = calculateCPM(placement.cost, placement.views_count); + const cost = placement.details?.cost?.value ?? null; + const subscriptions = placement.placement_post?.subscriptions_count ?? 0; + const views = placement.placement_post?.views_count ?? null; + const cps = calculateCPS(cost, subscriptions); + const cpm = calculateCPM(cost, views); return ( -
{placement.placement_channel_title}
+
{placement.channel.title}
-
{placement.creative_name}
+
- {formatDate(placement.placement_date)} - {formatCurrency(placement.cost)} - {formatNumber(placement.subscriptions_count)} - {formatNumber(placement.views_count ?? null)} + {placement.details?.placement_at ? formatDate(placement.details.placement_at) : "—"} + {formatCurrency(cost)} + {formatNumber(subscriptions)} + {formatNumber(views)} {cps ? formatCurrency(cps) : "—"} {cpm ? formatCurrency(cpm) : "—"} - {placement.status === "active" ? "Активен" : "Архив"} + {placement.status} @@ -677,10 +692,10 @@ export default function PlacementsPage() { Подробнее - {placement.ad_post_url && ( + {placement.placement_post?.post?.url && ( diff --git a/app/dashboard/[workspaceId]/projects/page.tsx b/app/dashboard/[workspaceId]/projects/page.tsx index 149069e..6612640 100644 --- a/app/dashboard/[workspaceId]/projects/page.tsx +++ b/app/dashboard/[workspaceId]/projects/page.tsx @@ -16,13 +16,18 @@ import { TrendingUp, Users, Eye, - ShoppingCart, Info, Bot, Settings, - Folder, + FolderKanban, Archive, + RotateCcw, Trash2, + Zap, + Target, + CircleDollarSign, + CircleQuestionMark, + ArrowRightFromLine, } from "lucide-react"; import { DashboardHeader } from "@/components/layout/dashboard-header"; import { useWorkspace } from "@/components/providers/workspace-provider"; @@ -80,6 +85,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; import { BOT_USERNAME } from "@/lib/utils/constants"; import type { Project, SpendingAnalytics } from "@/lib/types/api"; @@ -93,6 +99,8 @@ interface ProjectWithStats extends Project { total_subscriptions: number; total_views: number; avg_cpf: number | null; + avg_cpm: number | null; + placements_count: number; }; } @@ -115,6 +123,19 @@ const formatCurrency = (value: number | null | undefined): string => { }).format(value); }; +const formatStatus = (status: string): string => { + switch (status) { + case "active": + return "Активен"; + case "inactive": + return "Неактивен"; + case "archived": + return "В архиве"; + default: + return status; + } +}; + // ============================================================================ // Component // ============================================================================ @@ -122,7 +143,7 @@ const formatCurrency = (value: number | null | undefined): string => { export default function ProjectsPage() { const params = useParams(); const workspaceId = params?.workspaceId as string; - const { projects, isLoadingProjects, currentWorkspace, refreshProjects } = useWorkspace(); + const { projects, isLoadingProjects, currentWorkspace, refreshProjects, workspaces, isAdmin, isWorkspaceAdmin } = useWorkspace(); const [projectsWithStats, setProjectsWithStats] = useState([]); const [loadingStats, setLoadingStats] = useState(false); @@ -131,9 +152,18 @@ export default function ProjectsPage() { const [inviteLinkType, setInviteLinkType] = useState<"public" | "approval">("public"); const [isUpdatingSettings, setIsUpdatingSettings] = useState(false); const [isArchiving, setIsArchiving] = useState(false); + const [isUnarchiving, setIsUnarchiving] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [deleteConfirmationText, setDeleteConfirmationText] = useState(""); + + // Move project state + const [showMoveDialog, setShowMoveDialog] = useState(false); + const [targetWorkspaceId, setTargetWorkspaceId] = useState(""); + const [isMoving, setIsMoving] = useState(false); + const [targetWorkspaceAdmin, setTargetWorkspaceAdmin] = useState(false); + const [isCheckingTargetPermissions, setIsCheckingTargetPermissions] = useState(false); + const { isDemoMode } = useWorkspace(); const handleOpenSettings = (project: Project) => { @@ -175,6 +205,22 @@ export default function ProjectsPage() { } }; + const handleUnarchive = async () => { + if (!settingsProject) return; + + try { + setIsUnarchiving(true); + const api = isDemoMode ? demoAwareProjectsApi : projectsApi; + await api.unarchive(workspaceId, settingsProject.id); + await refreshProjects(); + setSettingsProject(null); + } catch (err) { + console.error("Failed to unarchive project:", err); + } finally { + setIsUnarchiving(false); + } + }; + const handleDelete = async () => { if (!settingsProject) return; @@ -202,8 +248,67 @@ export default function ProjectsPage() { setDeleteConfirmationText(""); }; + const handleOpenMoveDialog = async () => { + setShowMoveDialog(true); + setTargetWorkspaceId(""); + setTargetWorkspaceAdmin(false); + + // Check permissions for all other workspaces + setIsCheckingTargetPermissions(true); + const adminWorkspaces: string[] = []; + + for (const ws of workspaces) { + if (ws.id === workspaceId) continue; + const isAdmin = await isWorkspaceAdmin(ws.id); + if (isAdmin) { + adminWorkspaces.push(ws.id); + } + } + + // Set first admin workspace as default if available + if (adminWorkspaces.length > 0) { + setTargetWorkspaceId(adminWorkspaces[0]); + setTargetWorkspaceAdmin(true); + } + + setIsCheckingTargetPermissions(false); + }; + + const handleCloseMoveDialog = () => { + setShowMoveDialog(false); + setTargetWorkspaceId(""); + setTargetWorkspaceAdmin(false); + }; + + const handleTargetWorkspaceChange = async (newWorkspaceId: string) => { + setTargetWorkspaceId(newWorkspaceId); + setTargetWorkspaceAdmin(false); + + // Check if user is admin in target workspace + const isAdmin = await isWorkspaceAdmin(newWorkspaceId); + setTargetWorkspaceAdmin(isAdmin); + }; + + const handleMoveProject = async () => { + if (!settingsProject || !targetWorkspaceId || !targetWorkspaceAdmin) return; + + try { + setIsMoving(true); + const api = isDemoMode ? demoAwareProjectsApi : projectsApi; + await api.move(workspaceId, settingsProject.id, { target_workspace_id: targetWorkspaceId }); + await refreshProjects(); + setSettingsProject(null); + setShowMoveDialog(false); + setTargetWorkspaceId(""); + setTargetWorkspaceAdmin(false); + } catch (err) { + console.error("Failed to move project:", err); + } finally { + setIsMoving(false); + } + }; + const getChannelAvatar = (project: Project) => { - // Telegram channel avatar URL format if (project.username) { return `https://t.me/i/userpic/320/${project.username}.jpg`; } @@ -235,6 +340,11 @@ export default function ProjectsPage() { spending.total_subscriptions > 0 ? spending.total_cost / spending.total_subscriptions : null, + avg_cpm: + spending.total_views > 0 + ? (spending.total_cost / spending.total_views) * 1000 + : null, + placements_count: spending.placements_count || 0, }, }; } catch { @@ -275,27 +385,37 @@ export default function ProjectsPage() {

- - + + + + + Плиточный вид + + + + + + Табличный вид +
- + Чтобы добавить новый проект, добавьте бота{" "} @@ -334,7 +454,7 @@ export default function ProjectsPage() { ) : projectsWithStats.length === 0 ? ( - +

Нет проектов

Добавьте бота администратором в ваш Telegram канал, и он @@ -343,7 +463,6 @@ export default function ProjectsPage() { ) : viewMode === "table" ? ( - // Table View @@ -365,7 +484,7 @@ export default function ProjectsPage() {
- Привлечено подписчиков + Привлечено @@ -378,7 +497,7 @@ export default function ProjectsPage() {
- Суммарный охват + Охват @@ -389,19 +508,7 @@ export default function ProjectsPage() {
- -
- CPF - - - - - - Cost Per Follower — средняя стоимость подписчика - - -
-
+ CPF @@ -413,7 +520,7 @@ export default function ProjectsPage() { - +
@@ -435,25 +542,38 @@ export default function ProjectsPage() { - {project.status === "active" - ? "Активен" - : project.status === "inactive" - ? "Неактивен" - : "Архив"} + {formatStatus(project.status)} - + {formatNumber(project.subscribers_count)} - {formatNumber(project.stats?.total_subscriptions)} +
+ + {formatNumber(project.stats?.total_subscriptions)} + + {project.stats?.placements_count ? ( + + ({project.stats.placements_count} размещ.) + + ) : null} +
{formatNumber(project.stats?.total_views)} - {formatCurrency(project.stats?.avg_cpf)} +
+ + + {formatCurrency(project.stats?.avg_cpf)} + +
@@ -461,14 +581,15 @@ export default function ProjectsPage() { - План закупов + + Размещения @@ -477,8 +598,7 @@ export default function ProjectsPage() { size="sm" onClick={() => handleOpenSettings(project)} > - - Настройки +
@@ -488,31 +608,30 @@ export default function ProjectsPage() {
) : ( - // Grid View

{projectsWithStats.map((project) => ( - - -
-
- + + +
+
+ - + -
- {project.title} +
+ {project.title} {project.username && ( @{project.username} - + )} @@ -520,85 +639,88 @@ export default function ProjectsPage() {
- {project.status === "active" - ? "Активен" - : project.status === "inactive" - ? "Неактивен" - : "Архив"} + {formatStatus(project.status)}
- {/* Stats Grid */} -
- {project.subscribers_count !== undefined && ( -
- -
-
- {formatNumber(project.subscribers_count)} +
+
+ + Подписчиков +
+ + {formatNumber(project.subscribers_count)} + +
+ + {project.stats && ( + <> +
+
+
+
-
- Подписчиков +
+
Привлечено
+
+ {formatNumber(project.stats.total_subscriptions)} +
+
+
+
+
+ +
+
+
Охват
+
+ {formatNumber(project.stats.total_views)} +
- )} - {project.stats && ( - <> + +
- -
-
- {formatNumber(project.stats.total_subscriptions)} -
-
- Привлечено подписчиков -
+
+
-
-
-
-
- {formatNumber(project.stats.total_views)} -
-
- Суммарный охват -
-
-
-
- -
-
+
CPF
+
{formatCurrency(project.stats.avg_cpf)}
-
- CPF -
- - )} -
+ {project.stats.placements_count > 0 && ( + + {project.stats.placements_count} размещ. + + )} +
+ + )} - {/* Actions */} -
-
@@ -616,7 +737,6 @@ export default function ProjectsPage() {
)} - {/* Settings Dialog */} !open && setSettingsProject(null)}> @@ -641,45 +761,71 @@ export default function ProjectsPage() {

- Тип пригласительной ссылки, который будет использоваться по умолчанию при создании размещений для этого проекта + Тип пригласительной ссылки по умолчанию при создании размещений

- {/* Danger Zone */}
-
-

Опасная зона

-
- {settingsProject?.status !== "archived" && ( - - )} +

Действия

+
+ {settingsProject?.status === "archived" ? ( + ) : ( + -
+ )} + +
@@ -688,11 +834,11 @@ export default function ProjectsPage() { variant="outline" onClick={() => setSettingsProject(null)} > - Отмена + Закрыть + + + +
); diff --git a/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx b/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx index 5989a4a..8201b8c 100644 --- a/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx +++ b/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx @@ -1,36 +1,42 @@ "use client"; // ============================================================================ -// Purchase Plan Detail Page +// Placements Detail Page - All placements grouped by channel // ============================================================================ -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useState, useMemo, useCallback } from "react"; import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import { Loader2, Plus, ArrowLeft, - MoreHorizontal, - Pencil, - Trash2, + ChevronDown, + ChevronRight, + Search, + Filter, + Eye, + Users, ExternalLink, - Clock, - CheckCircle, - XCircle, - ArrowUpDown, - ShoppingCart, + 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 { purchasePlanApi, channelsApi } from "@/lib/api"; -import { demoAwarePurchasePlanApi, isDemoWorkspace, demoChannels } from "@/lib/demo"; +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 { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; import { Card, CardContent, @@ -39,30 +45,10 @@ import { CardTitle, } from "@/components/ui/card"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - TableFooter, -} from "@/components/ui/table"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import { Select, SelectContent, @@ -70,24 +56,26 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import type { Project, PurchasePlanChannel, Channel } from "@/lib/types/api"; +import { cn } from "@/lib/utils"; +import type { + Project, + Placement, + PlacementWithStats, + PlacementsGroupedByChannel, + PlacementStatus, +} from "@/lib/types/api"; // ============================================================================ // Helpers // ============================================================================ -const formatCurrency = (value: number | null) => { - if (value === null) return "—"; +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", @@ -96,39 +84,224 @@ const formatCurrency = (value: number | null) => { }).format(value); }; -const getStatusBadge = (status: string) => { +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 "planned": - return ( - - - Запланировано - - ); - case "completed": - return ( - - - Завершено - - ); - case "cancelled": - return ( - - - Отменено - - ); + 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 {status}; + return "bg-muted text-muted-foreground border-border"; } }; -type SortField = "channel" | "status" | "cost"; -type SortOrder = "asc" | "desc" | null; +// ============================================================================ +// 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 ( +
+ + +
+ + + {channelNumber} + + + + + {channel.title[0]} + + +
+ {channel.title} + {channel.username && ( + @{channel.username} + )} +
+ {channel.username && ( + + )} + + {placements.length} + +
+ + + {formatCompact(channel.subscribers_count)} + + + + {formatCompact(totalViews)} + +
+
+
+ +
+ + + + + + + + + + + + + + + + + + {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 ( + + + + + + + + + + + + + + ); + })} + +
СтатусДатаСтоимостьОхватПодпискиCPFCPMФорматКомментарий
+ + {channelNumber}.{idx + 1} + + + + {placement.status} + + + {formatDate(placement.details?.placement_at || null)} + + {formatCurrency(placement.details?.cost?.value || null)} + + {formatCompact(placement.placement_post?.views_count || null)} + + {formatNumber(placement.placement_post?.subscriptions_count || null)} + + {formatCurrency(cpf)} + + {formatCurrency(cpm)} + + {placement.details?.format || "—"} + + {placement.comment || "—"} +
+
+
+
+
+ ); +} // ============================================================================ -// Component +// Main Component // ============================================================================ export default function PurchasePlanDetailPage() { @@ -138,33 +311,33 @@ export default function PurchasePlanDetailPage() { const projectId = params?.projectId as string; const { projects, hasPermission } = useWorkspace(); - const [planChannels, setPlanChannels] = useState([]); + const [placements, setPlacements] = useState([]); const [loading, setLoading] = useState(true); const [project, setProject] = useState(null); - // Add channel dialog - const [showAddDialog, setShowAddDialog] = useState(false); - const [availableChannels, setAvailableChannels] = useState([]); - const [loadingChannels, setLoadingChannels] = useState(false); - const [selectedChannelId, setSelectedChannelId] = useState(""); - const [plannedCost, setPlannedCost] = useState(""); - const [comment, setComment] = useState(""); - const [isAdding, setIsAdding] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [expandedAll, setExpandedAll] = useState(true); + const [filtersOpen, setFiltersOpen] = useState(false); + const [filters, setFilters] = useState({ + 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, + }); - // Edit dialog - const [editingChannel, setEditingChannel] = useState(null); - const [editPlannedCost, setEditPlannedCost] = useState(""); - const [editComment, setEditComment] = useState(""); - const [isUpdating, setIsUpdating] = useState(false); - - // Delete confirmation - const [deletingChannel, setDeletingChannel] = useState(null); - - // Sorting - const [sortField, setSortField] = useState(null); - const [sortOrder, setSortOrder] = useState(null); - - const canWrite = hasPermission("placements_write"); + const { isDemoMode } = useWorkspace(); useEffect(() => { const proj = projects.find((p) => p.id === projectId); @@ -174,157 +347,183 @@ export default function PurchasePlanDetailPage() { }, [projects, projectId]); useEffect(() => { - loadPlanChannels(); + loadPlacements(); }, [workspaceId, projectId]); - const loadPlanChannels = async () => { + const loadPlacements = async () => { try { setLoading(true); - const response = await demoAwarePurchasePlanApi.list(workspaceId, projectId, { size: 100 }); - setPlanChannels(response.items); + const api = isDemoMode ? demoAwarePlacementsApi : placementsApi; + const data = await api.getByProject(workspaceId, projectId); + setPlacements(data); } catch (err) { - console.error("Failed to load purchase plan:", err); + console.error("Failed to load placements:", err); } finally { setLoading(false); } }; - const loadAvailableChannels = async () => { - try { - setLoadingChannels(true); - // In demo mode use mock channels - const isDemo = isDemoWorkspace(workspaceId); - const response = isDemo - ? { items: demoChannels, total: demoChannels.length, page: 1, size: 100, pages: 1 } - : await channelsApi.list({ size: 100 }); - // Filter out channels already in plan - const existingIds = new Set(planChannels.map((pc) => pc.channel.id)); - setAvailableChannels(response.items.filter((c) => !existingIds.has(c.id))); - } catch (err) { - console.error("Failed to load channels:", err); - } finally { - setLoadingChannels(false); - } - }; + const groupedPlacements = useMemo(() => { + const groups: Record = {}; - const handleOpenAddDialog = () => { - loadAvailableChannels(); - setSelectedChannelId(""); - setPlannedCost(""); - setComment(""); - setShowAddDialog(true); - }; + placements.forEach((placement) => { + const channelId = placement.channel.id; + if (!groups[channelId]) { + groups[channelId] = { + channel: placement.channel, + placements: [], + }; + } - const handleAddChannel = async () => { - if (!selectedChannelId) return; + 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; - try { - setIsAdding(true); - await purchasePlanApi.create(workspaceId, projectId, { - channel_id: selectedChannelId, - planned_cost: plannedCost ? parseFloat(plannedCost) : undefined, - comment: comment || undefined, + groups[channelId].placements.push({ + ...placement, + cpf, + cpm, }); - setShowAddDialog(false); - loadPlanChannels(); - } catch (err) { - console.error("Failed to add channel:", err); - } finally { - setIsAdding(false); - } - }; - - const handleOpenEditDialog = (channel: PurchasePlanChannel) => { - setEditingChannel(channel); - setEditPlannedCost(channel.planned_cost?.toString() || ""); - setEditComment(channel.comment || ""); - }; - - const handleUpdateChannel = async () => { - if (!editingChannel) return; - - try { - setIsUpdating(true); - await purchasePlanApi.update(workspaceId, projectId, editingChannel.id, { - planned_cost: editPlannedCost ? parseFloat(editPlannedCost) : undefined, - comment: editComment || undefined, - }); - setEditingChannel(null); - loadPlanChannels(); - } catch (err) { - console.error("Failed to update channel:", err); - } finally { - setIsUpdating(false); - } - }; - - const handleDeleteChannel = async () => { - if (!deletingChannel) return; - - try { - await purchasePlanApi.delete(workspaceId, projectId, deletingChannel.id); - setDeletingChannel(null); - loadPlanChannels(); - } catch (err) { - console.error("Failed to delete channel:", err); - } - }; - - // Sorting - const handleSort = (field: SortField) => { - if (sortField === field) { - if (sortOrder === "asc") setSortOrder("desc"); - else if (sortOrder === "desc") { - setSortField(null); - setSortOrder(null); - } - } else { - setSortField(field); - setSortOrder("asc"); - } - }; - - const sortedChannels = useMemo(() => { - if (!sortField || !sortOrder) return planChannels; - - return [...planChannels].sort((a, b) => { - let aVal: string | number = 0; - let bVal: string | number = 0; - - switch (sortField) { - case "channel": - aVal = a.channel.title.toLowerCase(); - bVal = b.channel.title.toLowerCase(); - break; - case "status": - aVal = a.status; - bVal = b.status; - break; - case "cost": - aVal = a.planned_cost ?? 0; - bVal = b.planned_cost ?? 0; - break; - } - - if (typeof aVal === "string") { - return sortOrder === "asc" - ? aVal.localeCompare(bVal as string) - : (bVal as string).localeCompare(aVal); - } - return sortOrder === "asc" ? aVal - (bVal as number) : (bVal as number) - aVal; }); - }, [planChannels, sortField, sortOrder]); - // Stats - const stats = useMemo(() => { - const planned = planChannels.filter((c) => c.status === "planned"); + 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 { - totalChannels: planChannels.length, - plannedCount: planned.length, - completedCount: planChannels.filter((c) => c.status === "completed").length, - totalPlannedCost: planned.reduce((sum, c) => sum + (c.planned_cost || 0), 0), + totalPlacements: placements.length, + totalChannels: groupedPlacements.length, + totalCost, + totalSubscriptions, + totalViews, + avgCpf: totalSubscriptions > 0 ? totalCost / totalSubscriptions : null, + avgCpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null, }; - }, [planChannels]); + }, [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 ( @@ -342,8 +541,8 @@ export default function PurchasePlanDetailPage() { @@ -360,382 +559,106 @@ export default function PurchasePlanDetailPage() {

- План закупов: {project?.title} + План закупов проекта «{project?.title || "Проект"}»

- {stats.totalChannels} каналов • Планируемый бюджет:{" "} - {formatCurrency(stats.totalPlannedCost)} + Список каналов, в которых планируем размещения для проекта

- - {canWrite && ( -
- - -
- )} + + + + +
- {/* Stats Cards */} -
- - - - Всего каналов - - - -
{stats.totalChannels}
-
-
- - - - Запланировано - - - -
{stats.plannedCount}
-
-
- - - - Завершено - - - -
{stats.completedCount}
-
-
- - - - Планируемый бюджет - - - -
- {formatCurrency(stats.totalPlannedCost)} -
-
-
+ {/* Filters */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ +
- {/* Table */} - {planChannels.length === 0 ? ( + {/* Placements List */} + {filteredGroups.length === 0 ? ( - -

План пуст

-

- Добавьте каналы для размещения рекламы + +

Нет размещений

+

+ {placements.length === 0 + ? "Добавьте первое размещение для этого проекта" + : "Попробуйте изменить параметры поиска"}

- {canWrite && ( - - )}
) : ( - - - - - - - - - - - - - - Комментарий - - - - - {sortedChannels.map((planChannel) => ( - - -
-
{planChannel.channel.title}
- {planChannel.channel.username && ( - e.stopPropagation()} - > - @{planChannel.channel.username} - - - )} -
-
- {getStatusBadge(planChannel.status)} - - {formatCurrency(planChannel.planned_cost)} - - - {planChannel.comment || "—"} - - - - - - - - - - - Создать размещение - - - {canWrite && ( - <> - - handleOpenEditDialog(planChannel)} - > - - Редактировать - - setDeletingChannel(planChannel)} - className="text-destructive" - > - - Удалить - - - )} - - - -
- ))} -
- - - - Итого запланировано: - - - {formatCurrency(stats.totalPlannedCost)} - - - - -
-
+
+ {filteredGroups.map((group, idx) => ( + + ))} +
)}
- {/* Add Channel Dialog */} - - - - Добавить канал в план - - Выберите канал из каталога для добавления в план закупов - - - -
-
- - {loadingChannels ? ( -
- - Загрузка каналов... -
- ) : ( - - )} -
- -
- - setPlannedCost(e.target.value)} - /> -
- -
- -