"use client"; // ============================================================================ // Projects Page (Target Channels) - Enhanced with Statistics // ============================================================================ import { useEffect, useRef, useState } from "react"; import { useParams } from "next/navigation"; import Link from "next/link"; import { Loader2, Tv, ExternalLink, Grid, List, TrendingUp, Users, Eye, Info, Bot, Settings, 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"; import { demoAwareAnalyticsApi, demoAwareProjectsApi } from "@/lib/demo"; import { projectsApi } from "@/lib/api"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Tooltip, TooltipContent, 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"; // ============================================================================ // Types // ============================================================================ interface ProjectWithStats extends Project { stats?: { total_cost: number; total_subscriptions: number; total_views: number; avg_cpf: number | null; avg_cpm: number | null; placements_count: number; }; } // ============================================================================ // 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 formatStatus = (status: string): string => { switch (status) { case "active": return "Активен"; case "inactive": return "Неактивен"; case "archived": return "В архиве"; default: return status; } }; // ============================================================================ // Component // ============================================================================ export default function ProjectsPage() { const params = useParams(); const workspaceId = params?.workspaceId as string; const { projects, isLoadingProjects, currentWorkspace, refreshProjects, workspaces, isAdmin, isWorkspaceAdmin } = useWorkspace(); const [projectsWithStats, setProjectsWithStats] = useState([]); const [loadingStats, setLoadingStats] = useState(false); const [viewMode, setViewMode] = useState<"grid" | "table">("grid"); const [settingsProject, setSettingsProject] = useState(null); 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(); // Poll projects list every 5s so newly added projects (via bot) appear automatically. const refreshProjectsRef = useRef(refreshProjects); useEffect(() => { refreshProjectsRef.current = refreshProjects; }, [refreshProjects]); useEffect(() => { if (!workspaceId) return; if (isDemoMode) return; const intervalId = window.setInterval(() => { if (typeof document !== "undefined" && document.hidden) return; void refreshProjectsRef.current(); }, 5000); return () => { window.clearInterval(intervalId); }; }, [workspaceId, isDemoMode]); const handleOpenSettings = (project: Project) => { setSettingsProject(project); setInviteLinkType(project.purchase_invite_type_default || "public"); }; const handleSaveSettings = async () => { if (!settingsProject) return; try { setIsUpdatingSettings(true); const api = isDemoMode ? demoAwareProjectsApi : projectsApi; await api.updateInviteLinkType(workspaceId, settingsProject.id, { purchase_invite_type_default: inviteLinkType, }); await refreshProjects(); setSettingsProject(null); } catch (err) { console.error("Failed to update settings:", err); } finally { setIsUpdatingSettings(false); } }; const handleArchive = async () => { if (!settingsProject) return; try { setIsArchiving(true); const api = isDemoMode ? demoAwareProjectsApi : projectsApi; await api.archive(workspaceId, settingsProject.id); await refreshProjects(); setSettingsProject(null); } catch (err) { console.error("Failed to archive project:", err); } finally { setIsArchiving(false); } }; 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; try { setIsDeleting(true); const api = isDemoMode ? demoAwareProjectsApi : projectsApi; await api.delete(workspaceId, settingsProject.id); await refreshProjects(); setSettingsProject(null); setShowDeleteDialog(false); setDeleteConfirmationText(""); } catch (err) { console.error("Failed to delete project:", err); } finally { setIsDeleting(false); } }; const handleOpenDeleteDialog = () => { setShowDeleteDialog(true); }; const handleCloseDeleteDialog = () => { setShowDeleteDialog(false); 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) => { if (project.username) { return `https://t.me/i/userpic/320/${project.username}.jpg`; } return null; }; // Load statistics for each project useEffect(() => { if (projects.length === 0) { setProjectsWithStats([]); return; } const loadStats = async () => { setLoadingStats(true); try { const statsPromises = projects.map(async (project) => { try { const spending = await demoAwareAnalyticsApi.spending(workspaceId, { project_id: project.id, }); return { ...project, stats: { total_cost: spending.total_cost, total_subscriptions: spending.total_subscriptions, total_views: spending.total_views, avg_cpf: 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 { return { ...project, stats: undefined }; } }); const results = await Promise.all(statsPromises); setProjectsWithStats(results); } catch (err) { console.error("Failed to load project stats:", err); setProjectsWithStats(projects); } finally { setLoadingStats(false); } }; loadStats(); }, [projects, workspaceId]); const isLoading = isLoadingProjects || loadingStats; return (

Проекты

Telegram каналы, которые вы продвигаете

Плиточный вид Табличный вид
Чтобы добавить новый проект, добавьте бота{" "} @{BOT_USERNAME} {" "} администратором в ваш Telegram канал. {isLoading ? (
) : projectsWithStats.length === 0 ? (

Нет проектов

Добавьте бота администратором в ваш Telegram канал, и он автоматически появится здесь как проект.

) : viewMode === "table" ? ( Канал Статус
Подписчиков Количество подписчиков в канале
Привлечено Привлеченные подписчики по пригласительным ссылкам
Охват Суммарные просмотры рекламных постов
CPF
{projectsWithStats.map((project) => (
{project.title}
{project.username && ( @{project.username} )}
{formatStatus(project.status)} {formatNumber(project.subscribers_count)}
{formatNumber(project.stats?.total_subscriptions)} {project.stats?.placements_count ? ( ({project.stats.placements_count} размещ.) ) : null}
{formatNumber(project.stats?.total_views)}
{formatCurrency(project.stats?.avg_cpf)}
))}
) : (
{projectsWithStats.map((project) => (
{project.title} {project.username && ( @{project.username} )}
{formatStatus(project.status)}
Подписчиков
{formatNumber(project.subscribers_count)}
{project.stats && ( <>
Привлечено
{formatNumber(project.stats.total_subscriptions)}
Охват
{formatNumber(project.stats.total_views)}
CPF
{formatCurrency(project.stats.avg_cpf)}
{project.stats.placements_count > 0 && ( {project.stats.placements_count} размещ. )}
)}
))}
)} !open && setSettingsProject(null)}> Настройки проекта {settingsProject?.title}

Тип пригласительной ссылки по умолчанию при создании размещений

Действия

{settingsProject?.status === "archived" ? ( ) : ( )}
Удалить проект "{settingsProject?.title}"? Это действие необратимо. Все данные проекта будут удалены, включая креативы, размещения и аналитику.

Для подтверждения введите Удалить {settingsProject?.title} ниже:
setDeleteConfirmationText(e.target.value)} className="mt-4" />
Отмена {isDeleting ? ( <> Удаление... ) : ( "Удалить" )}
{/* Move Project Dialog */} Переместить проект {settingsProject?.title}
{isCheckingTargetPermissions ? (
) : ( <>
{!targetWorkspaceId && (

Выберите воркспейс для перемещения проекта

)}
{targetWorkspaceId && !targetWorkspaceAdmin && (
У вас нет прав администратора в этом воркспейсе. Для перемещения проекта вы должны быть администратором в обоих воркспейсах.
)} {targetWorkspaceId && workspaces.find(w => w.id === targetWorkspaceId)?.avatar_url && (
Проект будет перемещён в воркспейс «{workspaces.find(w => w.id === targetWorkspaceId)?.name}»
)} )}
); }