1059 lines
41 KiB
TypeScript
1059 lines
41 KiB
TypeScript
"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<Project[]>([]);
|
||
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
|
||
|
||
const [localProjectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([]);
|
||
const [loadingStats, setLoadingStats] = useState(false);
|
||
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
|
||
const [settingsProject, setSettingsProject] = useState<Project | null>(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<string>("");
|
||
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 (
|
||
<TooltipProvider>
|
||
<DashboardHeader
|
||
breadcrumbs={[
|
||
{ label: "Главная", href: `/dashboard/${currentWorkspace?.id}` },
|
||
{ label: "Проекты" },
|
||
]}
|
||
/>
|
||
|
||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold tracking-tight">Проекты</h1>
|
||
<p className="text-muted-foreground">
|
||
Telegram каналы, которые вы продвигаете
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2 border rounded-lg p-1">
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||
size="sm"
|
||
onClick={() => setViewMode("grid")}
|
||
aria-label="Плиточный вид"
|
||
>
|
||
<Grid className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Плиточный вид</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant={viewMode === "table" ? "secondary" : "ghost"}
|
||
size="sm"
|
||
onClick={() => setViewMode("table")}
|
||
aria-label="Табличный вид"
|
||
>
|
||
<List className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Табличный вид</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
|
||
<Alert>
|
||
<CircleQuestionMark className="h-4 w-4 mt-1.5" />
|
||
<AlertDescription className="flex items-center justify-between flex-wrap gap-3">
|
||
<span>
|
||
Чтобы добавить новый проект, добавьте бота{" "}
|
||
<a
|
||
href={`https://t.me/${BOT_USERNAME}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="font-mono text-primary hover:underline inline-flex items-center gap-1"
|
||
>
|
||
@{BOT_USERNAME}
|
||
<ExternalLink className="h-3 w-3" />
|
||
</a>{" "}
|
||
администратором в ваш Telegram канал.
|
||
</span>
|
||
<Button
|
||
asChild
|
||
size="sm"
|
||
className="shrink-0"
|
||
>
|
||
<a
|
||
href={`tg://resolve?domain=${BOT_USERNAME}&startchannel&admin=invite_users+post_messages+edit_messages+delete_messages`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
<Bot className="h-4 w-4 mr-2" />
|
||
Добавить бота
|
||
</a>
|
||
</Button>
|
||
</AlertDescription>
|
||
</Alert>
|
||
|
||
<div className="flex items-center gap-4">
|
||
<div className="flex items-center gap-2">
|
||
<Checkbox
|
||
id="show-archived"
|
||
checked={showArchived}
|
||
onCheckedChange={(checked) => setShowArchived(checked === true)}
|
||
/>
|
||
<Label htmlFor="show-archived" className="text-sm cursor-pointer">
|
||
Показать архивированные
|
||
</Label>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={handleRefresh}
|
||
disabled={isLoadingProjects || isRefreshing}
|
||
>
|
||
<RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} />
|
||
Обновить
|
||
</Button>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="flex items-center justify-center py-12">
|
||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : filteredProjects.length === 0 ? (
|
||
<Card>
|
||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
|
||
<h3 className="text-lg font-semibold mb-2">Нет проектов</h3>
|
||
<p className="text-muted-foreground text-center max-w-md">
|
||
Добавьте бота администратором в ваш Telegram канал, и он
|
||
автоматически появится здесь как проект.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
) : viewMode === "table" ? (
|
||
<Card>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Канал</TableHead>
|
||
<TableHead>Статус</TableHead>
|
||
<TableHead className="text-right">
|
||
<div className="flex items-center justify-end gap-1">
|
||
Подписчиков
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
Количество подписчиков в канале
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</TableHead>
|
||
<TableHead className="text-right">
|
||
<div className="flex items-center justify-end gap-1">
|
||
Привлечено
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
Привлеченные подписчики по пригласительным ссылкам
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</TableHead>
|
||
<TableHead className="text-right">
|
||
<div className="flex items-center justify-end gap-1">
|
||
Охват
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
Суммарные просмотры рекламных постов
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</TableHead>
|
||
<TableHead className="text-right">CPF</TableHead>
|
||
<TableHead className="w-[200px]"></TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{filteredProjects.map((project) => (
|
||
<TableRow key={project.id}>
|
||
<TableCell>
|
||
<div className="flex items-center gap-3">
|
||
<Avatar className="h-9 w-9">
|
||
<AvatarImage src={getChannelAvatar(project) || undefined} alt={project.title} />
|
||
<AvatarFallback className="rounded-lg bg-primary/10">
|
||
<FolderKanban className="h-4 w-4 text-primary" />
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<div>
|
||
<div className="font-medium">{project.title}</div>
|
||
{project.username && (
|
||
<a
|
||
href={`https://t.me/${project.username}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-xs text-muted-foreground hover:underline inline-flex items-center gap-0.5"
|
||
>
|
||
@{project.username}
|
||
<ExternalLink className="h-2.5 w-2.5" />
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge
|
||
variant={project.status === "active" ? "default" : "secondary"}
|
||
className={cn(
|
||
project.status === "archived" && "bg-muted text-muted-foreground"
|
||
)}
|
||
>
|
||
{formatStatus(project.status)}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-right font-medium">
|
||
{formatNumber(project.subscribers_count)}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<div className="flex items-center justify-end gap-1">
|
||
<span className="font-medium">
|
||
{formatNumber(project.stats?.total_subscriptions)}
|
||
</span>
|
||
{project.stats?.placements_count ? (
|
||
<span className="text-xs text-muted-foreground">
|
||
({project.stats.placements_count} размещ.)
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
{formatNumber(project.stats?.total_views)}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<div className="flex items-center justify-end gap-1">
|
||
<CircleDollarSign className="h-3.5 w-3.5 text-muted-foreground" />
|
||
<span className="font-medium">
|
||
{formatCurrency(project.stats?.avg_cpf)}
|
||
</span>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex gap-1 flex-wrap">
|
||
<Button variant="outline" size="sm" asChild>
|
||
<Link
|
||
href={`/dashboard/${currentWorkspace?.id}/purchase-plans/${project.id}`}
|
||
>
|
||
<Zap className="h-3 w-3 mr-1" />
|
||
Размещения
|
||
</Link>
|
||
</Button>
|
||
<Button variant="outline" size="sm" asChild>
|
||
<Link
|
||
href={`/dashboard/${currentWorkspace?.id}/creatives?project_id=${project.id}`}
|
||
>
|
||
<Target className="h-3 w-3 mr-1" />
|
||
Креативы
|
||
</Link>
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => handleOpenSettings(project)}
|
||
disabled={!canEditProjects}
|
||
>
|
||
<Settings className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</Card>
|
||
) : (
|
||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||
{filteredProjects.map((project) => (
|
||
<Card key={project.id} className="overflow-hidden">
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex items-center gap-3 min-w-0">
|
||
<Avatar className="h-10 w-10 shrink-0">
|
||
<AvatarImage src={getChannelAvatar(project) || undefined} alt={project.title} />
|
||
<AvatarFallback className="rounded-lg bg-primary/10">
|
||
<FolderKanban className="h-5 w-5 text-primary" />
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<div className="min-w-0">
|
||
<CardTitle className="text-base truncate">{project.title}</CardTitle>
|
||
{project.username && (
|
||
<CardDescription>
|
||
<a
|
||
href={`https://t.me/${project.username}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="hover:underline inline-flex items-center gap-0.5 truncate"
|
||
>
|
||
@{project.username}
|
||
<ExternalLink className="h-2.5 w-2.5 shrink-0" />
|
||
</a>
|
||
</CardDescription>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<Badge
|
||
variant={project.status === "active" ? "default" : "secondary"}
|
||
className={cn(
|
||
"shrink-0",
|
||
project.status === "archived" && "bg-muted text-muted-foreground"
|
||
)}
|
||
>
|
||
{formatStatus(project.status)}
|
||
</Badge>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="flex items-center justify-between text-sm">
|
||
<div className="flex items-center gap-2 text-muted-foreground">
|
||
<Users className="h-4 w-4" />
|
||
<span>Подписчиков</span>
|
||
</div>
|
||
<span className="font-semibold">
|
||
{formatNumber(project.subscribers_count)}
|
||
</span>
|
||
</div>
|
||
|
||
{project.stats && (
|
||
<>
|
||
<div className="grid grid-cols-2 gap-3 pt-2 border-t">
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex items-center justify-center h-8 w-8 rounded-lg bg-primary/10">
|
||
<Target className="h-4 w-4 text-primary" />
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-muted-foreground">Привлечено</div>
|
||
<div className="font-semibold text-sm">
|
||
{formatNumber(project.stats.total_subscriptions)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex items-center justify-center h-8 w-8 rounded-lg bg-primary/10">
|
||
<Eye className="h-4 w-4 text-primary" />
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-muted-foreground">Охват</div>
|
||
<div className="font-semibold text-sm">
|
||
{formatNumber(project.stats.total_views)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between pt-2 border-t">
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex items-center justify-center h-8 w-8 rounded-lg bg-primary/10">
|
||
<CircleDollarSign className="h-4 w-4 text-primary" />
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-muted-foreground">CPF</div>
|
||
<div className="font-semibold">
|
||
{formatCurrency(project.stats.avg_cpf)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{project.stats.placements_count > 0 && (
|
||
<Badge variant="outline" className="text-xs">
|
||
{project.stats.placements_count} размещ.
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div className="flex gap-2 pt-2 border-t">
|
||
<Button variant="outline" size="sm" className="flex-1" asChild>
|
||
<Link
|
||
href={`/dashboard/${currentWorkspace?.id}/purchase-plans/${project.id}`}
|
||
>
|
||
<Zap className="h-3.5 w-3.5 mr-1.5" />
|
||
Размещения
|
||
</Link>
|
||
</Button>
|
||
<Button variant="outline" size="sm" asChild>
|
||
<Link
|
||
href={`/dashboard/${currentWorkspace?.id}/creatives?project_id=${project.id}`}
|
||
>
|
||
<Target className="h-3.5 w-3.5" />
|
||
</Link>
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => handleOpenSettings(project)}
|
||
disabled={!canEditProjects}
|
||
>
|
||
<Settings className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<Dialog open={!!settingsProject} onOpenChange={(open) => !open && setSettingsProject(null)}>
|
||
<DialogContent className="sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>Настройки проекта</DialogTitle>
|
||
<DialogDescription>
|
||
{settingsProject?.title}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4 py-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="invite-link-type">Тип ссылки по умолчанию</Label>
|
||
<Select
|
||
value={inviteLinkType}
|
||
onValueChange={(value) => setInviteLinkType(value as "public" | "approval")}
|
||
>
|
||
<SelectTrigger id="invite-link-type">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="public">Открытая</SelectItem>
|
||
<SelectItem value="approval">С заявками</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="text-xs text-muted-foreground">
|
||
Тип пригласительной ссылки по умолчанию при создании размещений
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-3 pt-4 border-t">
|
||
<h4 className="text-sm font-semibold">Действия</h4>
|
||
<div className="flex flex-col gap-2">
|
||
{settingsProject?.status === "archived" ? (
|
||
<Button
|
||
variant="outline"
|
||
onClick={handleUnarchive}
|
||
disabled={!canEditProjects || isDemoMode}
|
||
className="w-full justify-start"
|
||
>
|
||
<RotateCcw className="h-4 w-4 mr-2" />
|
||
Восстановить из архива
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
variant="outline"
|
||
onClick={handleArchive}
|
||
disabled={!canEditProjects || isDemoMode}
|
||
className="w-full justify-start"
|
||
>
|
||
<Archive className="h-4 w-4 mr-2" />
|
||
Архивировать проект
|
||
</Button>
|
||
)}
|
||
<Button
|
||
variant="outline"
|
||
onClick={handleOpenMoveDialog}
|
||
disabled={!isAdmin || !canEditProjects || isDemoMode}
|
||
className="w-full justify-start"
|
||
title={!isAdmin ? "Вы должны быть администратором для перемещения проекта" : !canEditProjects ? "Нет прав на редактирование проектов" : undefined}
|
||
>
|
||
<ArrowRightFromLine className="h-4 w-4 mr-2" />
|
||
Переместить в другой воркспейс
|
||
</Button>
|
||
<Button
|
||
variant="destructive"
|
||
onClick={handleOpenDeleteDialog}
|
||
disabled={!canEditProjects || isDemoMode || settingsProject?.status === "archived"}
|
||
className="w-full justify-start"
|
||
>
|
||
<Trash2 className="h-4 w-4 mr-2" />
|
||
Удалить проект
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setSettingsProject(null)}
|
||
>
|
||
Закрыть
|
||
</Button>
|
||
<Button
|
||
onClick={handleSaveSettings}
|
||
disabled={!canEditProjects || isDemoMode}
|
||
>
|
||
Сохранить
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<AlertDialog open={showDeleteDialog} onOpenChange={handleCloseDeleteDialog}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Удалить проект "{settingsProject?.title}"?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
Это действие необратимо. Все данные проекта будут удалены, включая креативы, размещения и аналитику.
|
||
<br /><br />
|
||
Для подтверждения введите <strong>Удалить {settingsProject?.title}</strong> ниже:
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<div className="space-y-4 py-4">
|
||
<Input
|
||
placeholder={`Удалить ${settingsProject?.title}`}
|
||
value={deleteConfirmationText}
|
||
onChange={(e) => setDeleteConfirmationText(e.target.value)}
|
||
className="mt-4"
|
||
/>
|
||
</div>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel onClick={handleCloseDeleteDialog}>
|
||
Отмена
|
||
</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
onClick={handleDelete}
|
||
disabled={deleteConfirmationText !== `Удалить ${settingsProject?.title}` || isDemoMode}
|
||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||
>
|
||
Удалить
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
|
||
{/* Move Project Dialog */}
|
||
<Dialog open={showMoveDialog} onOpenChange={handleCloseMoveDialog}>
|
||
<DialogContent className="sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>Переместить проект</DialogTitle>
|
||
<DialogDescription>
|
||
{settingsProject?.title}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4 py-4">
|
||
{isCheckingTargetPermissions ? (
|
||
<div className="flex items-center justify-center py-4">
|
||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="target-workspace">Воркспейс назначения</Label>
|
||
<Select
|
||
value={targetWorkspaceId}
|
||
onValueChange={handleTargetWorkspaceChange}
|
||
>
|
||
<SelectTrigger id="target-workspace">
|
||
<SelectValue placeholder="Выберите воркспейс" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{workspaces
|
||
.filter((ws) => ws.id !== workspaceId)
|
||
.map((ws) => (
|
||
<SelectItem key={ws.id} value={ws.id}>
|
||
{ws.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
{!targetWorkspaceId && (
|
||
<p className="text-xs text-muted-foreground">
|
||
Выберите воркспейс для перемещения проекта
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{targetWorkspaceId && !targetWorkspaceAdmin && (
|
||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||
У вас нет прав администратора в этом воркспейсе. Для перемещения проекта вы должны быть администратором в обоих воркспейсах.
|
||
</div>
|
||
)}
|
||
|
||
{targetWorkspaceId && workspaces.find(w => w.id === targetWorkspaceId)?.avatar_url && (
|
||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
<span>Проект будет перемещён в воркспейс «{workspaces.find(w => w.id === targetWorkspaceId)?.name}»</span>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
onClick={handleCloseMoveDialog}
|
||
>
|
||
Отмена
|
||
</Button>
|
||
<Button
|
||
onClick={handleMoveProject}
|
||
disabled={!targetWorkspaceId || !targetWorkspaceAdmin || isDemoMode}
|
||
>
|
||
<ArrowRightFromLine className="h-4 w-4 mr-2" />
|
||
Переместить
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
</TooltipProvider>
|
||
);
|
||
}
|