feat: global ui & functional update

This commit is contained in:
ivannoskov
2026-01-21 22:43:54 +03:00
parent 8958d89d12
commit 619fd5c1ef
32 changed files with 3803 additions and 1325 deletions

View File

@@ -1,26 +1,28 @@
"use client";
// ============================================================================
// Purchase Plans Overview Page
// Placements Overview Page - Projects with Placement Statistics
// ============================================================================
import { useEffect, useState } from "react";
import { useEffect, useState, useMemo } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import {
Loader2,
ClipboardList,
FolderKanban,
ChevronRight,
Tv,
Calendar,
Users,
Eye,
DollarSign,
CheckCircle,
Clock,
XCircle,
Target,
TrendingUp,
CircleDollarSign,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwarePurchasePlanApi } from "@/lib/demo";
import { demoAwarePlacementsApi } from "@/lib/demo";
import { placementsApi } from "@/lib/api";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -30,14 +32,20 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { Project, PurchasePlanChannel } from "@/lib/types/api";
import { cn } from "@/lib/utils";
import type { Project, ProjectPlacementsSummary, Placement } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatCurrency = (value: number | null) => {
if (value === null) return "—";
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",
@@ -46,13 +54,12 @@ const formatCurrency = (value: number | null) => {
}).format(value);
};
interface ProjectPlanStats {
totalChannels: number;
plannedChannels: number;
completedChannels: number;
cancelledChannels: number;
totalPlannedCost: number;
}
const formatCompact = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `${(value / 1000).toFixed(1)}K`;
return value.toString();
};
// ============================================================================
// Component
@@ -62,53 +69,57 @@ export default function PurchasePlansPage() {
const params = useParams();
const router = useRouter();
const workspaceId = params?.workspaceId as string;
const { projects, isLoadingProjects } = useWorkspace();
const { projects, isLoadingProjects, currentWorkspace } = useWorkspace();
const [projectStats, setProjectStats] = useState<Record<string, ProjectPlanStats>>({});
const [placementsByProject, setPlacementsByProject] = useState<Record<string, Placement[]>>({});
const [loadingStats, setLoadingStats] = useState(true);
const { isDemoMode } = useWorkspace();
useEffect(() => {
if (projects.length > 0) {
loadAllStats();
loadAllPlacements();
} else {
setLoadingStats(false);
}
}, [projects, workspaceId]);
const loadAllStats = async () => {
const loadAllPlacements = async () => {
setLoadingStats(true);
const stats: Record<string, ProjectPlanStats> = {};
const placementsMap: Record<string, Placement[]> = {};
for (const project of projects) {
try {
const response = await demoAwarePurchasePlanApi.list(workspaceId, project.id, { size: 100 });
const channels = response.items;
stats[project.id] = {
totalChannels: channels.length,
plannedChannels: channels.filter((c) => c.status === "planned").length,
completedChannels: channels.filter((c) => c.status === "completed").length,
cancelledChannels: channels.filter((c) => c.status === "cancelled").length,
totalPlannedCost: channels
.filter((c) => c.status === "planned")
.reduce((sum, c) => sum + (c.planned_cost || 0), 0),
};
const api = isDemoMode ? demoAwarePlacementsApi : placementsApi;
const placements = await api.getByProject(workspaceId, project.id);
placementsMap[project.id] = placements;
} catch (err) {
console.error(`Failed to load plan for project ${project.id}:`, err);
stats[project.id] = {
totalChannels: 0,
plannedChannels: 0,
completedChannels: 0,
cancelledChannels: 0,
totalPlannedCost: 0,
};
console.error(`Failed to load placements for project ${project.id}:`, err);
placementsMap[project.id] = [];
}
}
setProjectStats(stats);
setPlacementsByProject(placementsMap);
setLoadingStats(false);
};
const calculateProjectSummary = (placements: Placement[]): ProjectPlacementsSummary => {
const total_cost = placements.reduce((sum, p) => sum + (p.details?.cost?.value || 0), 0);
const total_subscriptions = placements.reduce((sum, p) => sum + (p.placement_post?.subscriptions_count || 0), 0);
const total_views = placements.reduce((sum, p) => sum + (p.placement_post?.views_count || 0), 0);
const channelsSet = new Set(placements.map(p => p.channel.id));
return {
total_placements: placements.length,
total_channels: channelsSet.size,
total_cost,
total_subscriptions,
total_views,
avg_cpf: total_subscriptions > 0 ? total_cost / total_subscriptions : null,
avg_cpm: total_views > 0 ? (total_cost / total_views) * 1000 : null,
};
};
const isLoading = isLoadingProjects || loadingStats;
return (
@@ -116,16 +127,16 @@ export default function PurchasePlansPage() {
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Планы закупов" },
{ 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>
<h1 className="text-2xl font-bold tracking-tight">Размещения</h1>
<p className="text-muted-foreground">
Управление планами закупов по проектам
Статистика по всем размещениям ваших проектов
</p>
</div>
</div>
@@ -137,10 +148,10 @@ export default function PurchasePlansPage() {
) : projects.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<ClipboardList className="h-12 w-12 text-muted-foreground mb-4" />
<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 mb-4">
Добавьте проект через Telegram бота для создания плана закупов
Добавьте проект через Telegram бота для отслеживания размещений
</p>
<Button asChild>
<Link href={`/dashboard/${workspaceId}/projects`}>
@@ -152,11 +163,14 @@ export default function PurchasePlansPage() {
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => {
const stats = projectStats[project.id];
const placements = placementsByProject[project.id] || [];
const summary = calculateProjectSummary(placements);
const avatarUrl = project.username
? `https://t.me/i/userpic/320/${project.username}.jpg`
: null;
return (
<Card
key={project.id}
<Card key={project.id}
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() =>
router.push(`/dashboard/${workspaceId}/purchase-plans/${project.id}`)
@@ -165,9 +179,12 @@ export default function PurchasePlansPage() {
<CardHeader className="pb-3">
<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>
<Avatar className="h-10 w-10">
<AvatarImage src={avatarUrl || undefined} alt={project.title} />
<AvatarFallback className="rounded-lg bg-primary/10">
<FolderKanban className="h-5 w-5 text-primary" />
</AvatarFallback>
</Avatar>
<div>
<CardTitle className="text-lg">{project.title}</CardTitle>
{project.username && (
@@ -179,46 +196,62 @@ export default function PurchasePlansPage() {
</div>
</CardHeader>
<CardContent>
{stats ? (
{summary.total_placements > 0 ? (
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Всего каналов:</span>
<span className="font-medium">{stats.totalChannels}</span>
</div>
<div className="flex gap-2 flex-wrap">
{stats.plannedChannels > 0 && (
<Badge variant="outline" className="gap-1">
<Clock className="h-3 w-3" />
{stats.plannedChannels} план.
</Badge>
)}
{stats.completedChannels > 0 && (
<Badge variant="default" className="gap-1">
<CheckCircle className="h-3 w-3" />
{stats.completedChannels} заверш.
</Badge>
)}
{stats.cancelledChannels > 0 && (
<Badge variant="secondary" className="gap-1">
<XCircle className="h-3 w-3" />
{stats.cancelledChannels} отмен.
</Badge>
)}
</div>
{stats.totalPlannedCost > 0 && (
<div className="flex items-center gap-2 text-sm pt-2 border-t">
<DollarSign className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">План. бюджет:</span>
<span className="font-medium ml-auto">
{formatCurrency(stats.totalPlannedCost)}
</span>
<div className="grid grid-cols-2 gap-3">
<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">{formatNumber(summary.total_placements)}</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">
<Users className="h-4 w-4 text-primary" />
</div>
<div>
<div className="text-xs text-muted-foreground">Каналов</div>
<div className="font-semibold">{formatNumber(summary.total_channels)}</div>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Охват:</span>
<span className="font-medium">{formatCompact(summary.total_views)}</span>
</div>
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Подписок:</span>
<span className="font-medium">{formatCompact(summary.total_subscriptions)}</span>
</div>
<div className="pt-2 border-t space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<CircleDollarSign className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Бюджет</span>
</div>
<span className="font-semibold">{formatCurrency(summary.total_cost)}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">CPF</span>
</div>
<span className="font-medium">{formatCurrency(summary.avg_cpf)}</span>
</div>
</div>
</div>
) : (
<div className="text-sm text-muted-foreground">Загрузка...</div>
<div className="text-sm text-muted-foreground">
Нет размещений
</div>
)}
</CardContent>
</Card>
@@ -230,4 +263,3 @@ export default function PurchasePlansPage() {
</>
);
}