458 lines
18 KiB
TypeScript
458 lines
18 KiB
TypeScript
"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,
|
||
} from "lucide-react";
|
||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||
import { demoAwareAnalyticsApi } from "@/lib/demo";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
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_cps: 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 } = useWorkspace();
|
||
|
||
const [projectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([]);
|
||
const [loadingStats, setLoadingStats] = useState(false);
|
||
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
|
||
|
||
// 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_cps:
|
||
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>
|
||
Чтобы добавить новый проект, добавьте бота{" "}
|
||
<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 канал.
|
||
</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">
|
||
Сред. CPS
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
Cost Per Subscription — средняя стоимость подписчика
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</TableHead>
|
||
<TableHead className="w-[140px]"></TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{projectsWithStats.map((project) => (
|
||
<TableRow key={project.id}>
|
||
<TableCell>
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||
<Tv className="h-4 w-4 text-primary" />
|
||
</div>
|
||
<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 font-medium">
|
||
{formatCurrency(project.stats?.total_cost)}
|
||
</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_cps)}
|
||
</TableCell>
|
||
<TableCell>
|
||
<Button variant="outline" size="sm" asChild>
|
||
<Link
|
||
href={`/dashboard/${currentWorkspace?.id}/projects/${project.id}/purchase-plan`}
|
||
>
|
||
План закупа
|
||
</Link>
|
||
</Button>
|
||
</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">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||
<Tv className="h-5 w-5 text-primary" />
|
||
</div>
|
||
<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 */}
|
||
{project.stats && (
|
||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||
<div className="flex items-center gap-2">
|
||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||
<div>
|
||
<div className="font-medium">
|
||
{formatCurrency(project.stats.total_cost)}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">
|
||
Потрачено
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<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_cps)}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">
|
||
Сред. CPS
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Actions */}
|
||
<div className="flex gap-2">
|
||
{project.username && (
|
||
<Button variant="outline" size="sm" asChild>
|
||
<a
|
||
href={`https://t.me/${project.username}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
<ExternalLink className="h-4 w-4 mr-1" />
|
||
Канал
|
||
</a>
|
||
</Button>
|
||
)}
|
||
<Button variant="outline" size="sm" asChild>
|
||
<Link
|
||
href={`/dashboard/${currentWorkspace?.id}/projects/${project.id}/purchase-plan`}
|
||
>
|
||
План закупа
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</TooltipProvider>
|
||
);
|
||
}
|