feat: global ui & functional update
This commit is contained in:
@@ -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() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user