Files
tgex-frontend/app/dashboard/[workspaceId]/projects/page.tsx
2026-01-07 17:54:58 +03:00

754 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// ============================================================================
// Projects Page (Target Channels) - Enhanced with Statistics
// ============================================================================
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import {
Loader2,
Tv,
ExternalLink,
Grid,
List,
TrendingUp,
Users,
Eye,
ShoppingCart,
Info,
Bot,
Settings,
Folder,
Archive,
Trash2,
} 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 { 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;
};
}
// ============================================================================
// 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);
};
// ============================================================================
// Component
// ============================================================================
export default function ProjectsPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { projects, isLoadingProjects, currentWorkspace, refreshProjects } = useWorkspace();
const [projectsWithStats, 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 [isUpdatingSettings, setIsUpdatingSettings] = useState(false);
const [isArchiving, setIsArchiving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteConfirmationText, setDeleteConfirmationText] = useState("");
const { isDemoMode } = useWorkspace();
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 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 getChannelAvatar = (project: Project) => {
// Telegram channel avatar URL format
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,
},
};
} 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 (
<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">
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
aria-label="Плиточный вид"
>
<Grid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === "table" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
aria-label="Табличный вид"
>
<List className="h-4 w-4" />
</Button>
</div>
</div>
<Alert>
<Tv className="h-4 w-4" />
<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>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : projectsWithStats.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Tv 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" ? (
// Table View
<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">
<div className="flex items-center justify-end gap-1">
CPF
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Cost Per Follower средняя стоимость подписчика
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projectsWithStats.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">
<Tv 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"}
>
{project.status === "active"
? "Активен"
: project.status === "inactive"
? "Неактивен"
: "Архив"}
</Badge>
</TableCell>
<TableCell className="text-right">
{formatNumber(project.subscribers_count)}
</TableCell>
<TableCell className="text-right">
{formatNumber(project.stats?.total_subscriptions)}
</TableCell>
<TableCell className="text-right">
{formatNumber(project.stats?.total_views)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(project.stats?.avg_cpf)}
</TableCell>
<TableCell>
<div className="flex gap-1 flex-wrap">
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/${currentWorkspace?.id}/purchase-plans/${project.id}`}
>
План закупов
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/${currentWorkspace?.id}/creatives?project_id=${project.id}`}
>
<Folder className="h-3 w-3 mr-1" />
Креативы
</Link>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenSettings(project)}
>
<Settings className="h-3 w-3 mr-1" />
Настройки
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
) : (
// Grid View
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projectsWithStats.map((project) => (
<Card key={project.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={getChannelAvatar(project) || undefined} alt={project.title} />
<AvatarFallback className="rounded-lg bg-primary/10">
<Tv className="h-5 w-5 text-primary" />
</AvatarFallback>
</Avatar>
<div>
<CardTitle className="text-base">{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"
>
@{project.username}
<ExternalLink className="h-2.5 w-2.5" />
</a>
</CardDescription>
)}
</div>
</div>
<Badge
variant={project.status === "active" ? "default" : "secondary"}
>
{project.status === "active"
? "Активен"
: project.status === "inactive"
? "Неактивен"
: "Архив"}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-3 text-sm">
{project.subscribers_count !== undefined && (
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatNumber(project.subscribers_count)}
</div>
<div className="text-xs text-muted-foreground">
Подписчиков
</div>
</div>
</div>
)}
{project.stats && (
<>
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatNumber(project.stats.total_subscriptions)}
</div>
<div className="text-xs text-muted-foreground">
Привлечено подписчиков
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatNumber(project.stats.total_views)}
</div>
<div className="text-xs text-muted-foreground">
Суммарный охват
</div>
</div>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatCurrency(project.stats.avg_cpf)}
</div>
<div className="text-xs text-muted-foreground">
CPF
</div>
</div>
</div>
</>
)}
</div>
{/* Actions */}
<div className="flex gap-2 flex-wrap">
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/${currentWorkspace?.id}/purchase-plans/${project.id}`}
>
План закупов
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/${currentWorkspace?.id}/creatives?project_id=${project.id}`}
>
<Folder className="h-4 w-4 mr-1" />
Креативы
</Link>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenSettings(project)}
>
<Settings className="h-4 w-4 mr-1" />
Настройки
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Settings Dialog */}
<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>
{/* Danger Zone */}
<div className="space-y-3 pt-4 border-t">
<div className="space-y-2">
<h4 className="text-sm font-semibold text-destructive">Опасная зона</h4>
<div className="flex flex-col gap-2">
{settingsProject?.status !== "archived" && (
<Button
variant="outline"
onClick={handleArchive}
disabled={isArchiving || isDeleting || isUpdatingSettings}
className="w-full justify-start"
>
{isArchiving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Архивирование...
</>
) : (
<>
<Archive className="h-4 w-4 mr-2" />
Архивировать проект
</>
)}
</Button>
)}
<Button
variant="destructive"
onClick={handleOpenDeleteDialog}
disabled={isArchiving || isDeleting || isUpdatingSettings}
className="w-full justify-start"
>
<Trash2 className="h-4 w-4 mr-2" />
Удалить проект
</Button>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setSettingsProject(null)}
>
Отмена
</Button>
<Button
onClick={handleSaveSettings}
disabled={isUpdatingSettings || isArchiving || isDeleting}
>
{isUpdatingSettings ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Сохранение...
</>
) : (
"Сохранить"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation 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={isDeleting || deleteConfirmationText !== `Удалить ${settingsProject?.title}`}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Удаление...
</>
) : (
"Удалить"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TooltipProvider>
);
}