"use client"; // ============================================================================ // Projects Page (Target Channels) - Enhanced with Statistics // ============================================================================ import { useEffect, useCallback, 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, RefreshCw, 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 { Checkbox } from "@/components/ui/checkbox"; 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 { toast } from "sonner"; import { handleApiError, showPermissionErrorToast } from "@/lib/utils/error-handler"; 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 { currentWorkspace, workspaces, isAdmin, isWorkspaceAdmin, hasPermission, isDemoMode } = useWorkspace(); // Local state for projects with proper include_archived support const [localProjects, setLocalProjects] = useState([]); const [isLoadingProjects, setIsLoadingProjects] = useState(true); const [localProjectsWithStats, 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 [showDeleteDialog, setShowDeleteDialog] = useState(false); const [deleteConfirmationText, setDeleteConfirmationText] = useState(""); // Move project state const [showMoveDialog, setShowMoveDialog] = useState(false); const [targetWorkspaceId, setTargetWorkspaceId] = useState(""); const [targetWorkspaceAdmin, setTargetWorkspaceAdmin] = useState(false); const [isCheckingTargetPermissions, setIsCheckingTargetPermissions] = useState(false); // Show archived projects toggle - must be defined before loadProjects const [showArchived, setShowArchived] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); // Load projects with include_archived support const loadProjects = useCallback(async () => { try { setIsLoadingProjects(true); if (isDemoMode) { const demoProjectsRaw = (await import("@/lib/demo")).demoProjects; const projects = showArchived ? demoProjectsRaw : demoProjectsRaw.filter(p => p.status === "active"); setLocalProjects(projects); } else { const response = await projectsApi.list(workspaceId, { size: 100, include_archived: showArchived }); setLocalProjects(response.items); } } catch (err) { console.error("Failed to load projects:", err); } finally { setIsLoadingProjects(false); } }, [workspaceId, showArchived, isDemoMode]); // Manual refresh - checks for new projects const handleRefresh = async () => { setIsRefreshing(true); await loadProjects(); setIsRefreshing(false); }; // Initial load and when showArchived changes useEffect(() => { loadProjects(); }, [loadProjects]); const handleOpenSettings = (project: Project) => { setSettingsProject(project); setInviteLinkType(project.purchase_invite_type_default || "public"); }; const handleSaveSettings = async () => { if (!settingsProject) return; await toast.promise( (async () => { const api = isDemoMode ? demoAwareProjectsApi : projectsApi; await api.updateInviteLinkType(workspaceId, settingsProject.id, { purchase_invite_type_default: inviteLinkType, }); await loadProjects(); })(), { loading: "Сохранение настроек...", success: () => { setSettingsProject(null); return "Настройки сохранены"; }, error: (err) => { handleApiError(err); return "Не удалось сохранить настройки"; }, } ); }; const handleArchive = async () => { if (!settingsProject) return; await toast.promise( (async () => { const api = isDemoMode ? demoAwareProjectsApi : projectsApi; await api.archive(workspaceId, settingsProject.id); await loadProjects(); })(), { loading: "Архивирование проекта...", success: () => { setSettingsProject(null); return `Проект «${settingsProject.title}» архивирован`; }, error: (err) => { handleApiError(err); return "Не удалось архивировать проект"; }, } ); }; const handleUnarchive = async () => { if (!settingsProject) return; await toast.promise( (async () => { const api = isDemoMode ? demoAwareProjectsApi : projectsApi; await api.unarchive(workspaceId, settingsProject.id); await loadProjects(); })(), { loading: "Восстановление проекта...", success: () => { setSettingsProject(null); return `Проект «${settingsProject.title}» восстановлен`; }, error: (err) => { handleApiError(err); return "Не удалось восстановить проект"; }, } ); }; const handleDelete = async () => { if (!settingsProject) return; await toast.promise( (async () => { const api = isDemoMode ? demoAwareProjectsApi : projectsApi; await api.delete(workspaceId, settingsProject.id); await loadProjects(); })(), { loading: "Удаление проекта...", success: () => { setSettingsProject(null); setShowDeleteDialog(false); setDeleteConfirmationText(""); return `Проект «${settingsProject.title}» удалён`; }, error: (err) => { handleApiError(err); return "Не удалось удалить проект"; }, } ); }; 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; const targetWorkspace = workspaces.find(w => w.id === targetWorkspaceId); await toast.promise( (async () => { const api = isDemoMode ? demoAwareProjectsApi : projectsApi; await api.move(workspaceId, settingsProject.id, { target_workspace_id: targetWorkspaceId }); await loadProjects(); })(), { loading: "Перемещение проекта...", success: () => { setSettingsProject(null); setShowMoveDialog(false); setTargetWorkspaceId(""); setTargetWorkspaceAdmin(false); return `Проект перемещён в «${targetWorkspace?.name}»`; }, error: (err) => { handleApiError(err); return "Не удалось переместить проект"; }, } ); }; const getChannelAvatar = (project: Project) => { if (project.username) { return `https://t.me/i/userpic/320/${project.username}.jpg`; } return null; }; // Check if user has any analytics permission const canViewAnalytics = hasPermission("analytics_read") || hasPermission("analytics_without_clicks") || hasPermission("analytics_own_creatives"); // Check if user has subscriptions visibility const canViewSubscriptions = hasPermission("analytics_read") || hasPermission("analytics_own_creatives"); // Check if user can edit projects const canEditProjects = hasPermission("projects_write"); // Load statistics for each project useEffect(() => { if (localProjects.length === 0) { setProjectsWithStats([]); return; } if (!canViewAnalytics) { setProjectsWithStats(localProjects.map(p => ({ ...p, stats: undefined }))); setLoadingStats(false); return; } const loadStats = async () => { setLoadingStats(true); try { const statsPromises = localProjects.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(localProjects); } finally { setLoadingStats(false); } }; loadStats(); }, [localProjects, workspaceId, canViewAnalytics]); const isLoading = isLoadingProjects || loadingStats; // Filter projects based on archived status (already filtered by API, but keep for safety) const filteredProjects = localProjectsWithStats.filter((project) => { if (showArchived) return true; return project.status !== "archived"; }); return (

Проекты

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

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

Нет проектов

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

) : viewMode === "table" ? ( Канал Статус
Подписчиков Количество подписчиков в канале
Привлечено Привлеченные подписчики по пригласительным ссылкам
Охват Суммарные просмотры рекламных постов
CPF
{filteredProjects.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)}
))}
) : (
{filteredProjects.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" />
Отмена Удалить
{/* 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}»
)} )}
); }