feat: global platform v2 update

This commit is contained in:
ivannoskov
2025-12-29 10:12:13 +03:00
parent 8e6a1fa83f
commit f64cf02100
84 changed files with 11023 additions and 11486 deletions

View File

@@ -0,0 +1,742 @@
"use client";
// ============================================================================
// Purchase Plan Detail Page
// ============================================================================
import { useEffect, useState, useMemo } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import {
Loader2,
Plus,
ArrowLeft,
MoreHorizontal,
Pencil,
Trash2,
ExternalLink,
Clock,
CheckCircle,
XCircle,
ArrowUpDown,
ShoppingCart,
RefreshCw,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { purchasePlanApi, channelsApi } from "@/lib/api";
import { demoAwarePurchasePlanApi, isDemoWorkspace, demoChannels } from "@/lib/demo";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableFooter,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import type { Project, PurchasePlanChannel, Channel } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatCurrency = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const getStatusBadge = (status: string) => {
switch (status) {
case "planned":
return (
<Badge variant="outline" className="gap-1">
<Clock className="h-3 w-3" />
Запланировано
</Badge>
);
case "completed":
return (
<Badge variant="default" className="gap-1">
<CheckCircle className="h-3 w-3" />
Завершено
</Badge>
);
case "cancelled":
return (
<Badge variant="secondary" className="gap-1">
<XCircle className="h-3 w-3" />
Отменено
</Badge>
);
default:
return <Badge variant="outline">{status}</Badge>;
}
};
type SortField = "channel" | "status" | "cost";
type SortOrder = "asc" | "desc" | null;
// ============================================================================
// Component
// ============================================================================
export default function PurchasePlanDetailPage() {
const params = useParams();
const router = useRouter();
const workspaceId = params?.workspaceId as string;
const projectId = params?.projectId as string;
const { projects, hasPermission } = useWorkspace();
const [planChannels, setPlanChannels] = useState<PurchasePlanChannel[]>([]);
const [loading, setLoading] = useState(true);
const [project, setProject] = useState<Project | null>(null);
// Add channel dialog
const [showAddDialog, setShowAddDialog] = useState(false);
const [availableChannels, setAvailableChannels] = useState<Channel[]>([]);
const [loadingChannels, setLoadingChannels] = useState(false);
const [selectedChannelId, setSelectedChannelId] = useState("");
const [plannedCost, setPlannedCost] = useState("");
const [comment, setComment] = useState("");
const [isAdding, setIsAdding] = useState(false);
// Edit dialog
const [editingChannel, setEditingChannel] = useState<PurchasePlanChannel | null>(null);
const [editPlannedCost, setEditPlannedCost] = useState("");
const [editComment, setEditComment] = useState("");
const [isUpdating, setIsUpdating] = useState(false);
// Delete confirmation
const [deletingChannel, setDeletingChannel] = useState<PurchasePlanChannel | null>(null);
// Sorting
const [sortField, setSortField] = useState<SortField | null>(null);
const [sortOrder, setSortOrder] = useState<SortOrder>(null);
const canWrite = hasPermission("placements_write");
useEffect(() => {
const proj = projects.find((p) => p.id === projectId);
if (proj) {
setProject(proj);
}
}, [projects, projectId]);
useEffect(() => {
loadPlanChannels();
}, [workspaceId, projectId]);
const loadPlanChannels = async () => {
try {
setLoading(true);
const response = await demoAwarePurchasePlanApi.list(workspaceId, projectId, { size: 100 });
setPlanChannels(response.items);
} catch (err) {
console.error("Failed to load purchase plan:", err);
} finally {
setLoading(false);
}
};
const loadAvailableChannels = async () => {
try {
setLoadingChannels(true);
// In demo mode use mock channels
const isDemo = isDemoWorkspace(workspaceId);
const response = isDemo
? { items: demoChannels, total: demoChannels.length, page: 1, size: 100, pages: 1 }
: await channelsApi.list({ size: 100 });
// Filter out channels already in plan
const existingIds = new Set(planChannels.map((pc) => pc.channel.id));
setAvailableChannels(response.items.filter((c) => !existingIds.has(c.id)));
} catch (err) {
console.error("Failed to load channels:", err);
} finally {
setLoadingChannels(false);
}
};
const handleOpenAddDialog = () => {
loadAvailableChannels();
setSelectedChannelId("");
setPlannedCost("");
setComment("");
setShowAddDialog(true);
};
const handleAddChannel = async () => {
if (!selectedChannelId) return;
try {
setIsAdding(true);
await purchasePlanApi.create(workspaceId, projectId, {
channel_id: selectedChannelId,
planned_cost: plannedCost ? parseFloat(plannedCost) : undefined,
comment: comment || undefined,
});
setShowAddDialog(false);
loadPlanChannels();
} catch (err) {
console.error("Failed to add channel:", err);
} finally {
setIsAdding(false);
}
};
const handleOpenEditDialog = (channel: PurchasePlanChannel) => {
setEditingChannel(channel);
setEditPlannedCost(channel.planned_cost?.toString() || "");
setEditComment(channel.comment || "");
};
const handleUpdateChannel = async () => {
if (!editingChannel) return;
try {
setIsUpdating(true);
await purchasePlanApi.update(workspaceId, projectId, editingChannel.id, {
planned_cost: editPlannedCost ? parseFloat(editPlannedCost) : undefined,
comment: editComment || undefined,
});
setEditingChannel(null);
loadPlanChannels();
} catch (err) {
console.error("Failed to update channel:", err);
} finally {
setIsUpdating(false);
}
};
const handleDeleteChannel = async () => {
if (!deletingChannel) return;
try {
await purchasePlanApi.delete(workspaceId, projectId, deletingChannel.id);
setDeletingChannel(null);
loadPlanChannels();
} catch (err) {
console.error("Failed to delete channel:", err);
}
};
// Sorting
const handleSort = (field: SortField) => {
if (sortField === field) {
if (sortOrder === "asc") setSortOrder("desc");
else if (sortOrder === "desc") {
setSortField(null);
setSortOrder(null);
}
} else {
setSortField(field);
setSortOrder("asc");
}
};
const sortedChannels = useMemo(() => {
if (!sortField || !sortOrder) return planChannels;
return [...planChannels].sort((a, b) => {
let aVal: string | number = 0;
let bVal: string | number = 0;
switch (sortField) {
case "channel":
aVal = a.channel.title.toLowerCase();
bVal = b.channel.title.toLowerCase();
break;
case "status":
aVal = a.status;
bVal = b.status;
break;
case "cost":
aVal = a.planned_cost ?? 0;
bVal = b.planned_cost ?? 0;
break;
}
if (typeof aVal === "string") {
return sortOrder === "asc"
? aVal.localeCompare(bVal as string)
: (bVal as string).localeCompare(aVal);
}
return sortOrder === "asc" ? aVal - (bVal as number) : (bVal as number) - aVal;
});
}, [planChannels, sortField, sortOrder]);
// Stats
const stats = useMemo(() => {
const planned = planChannels.filter((c) => c.status === "planned");
return {
totalChannels: planChannels.length,
plannedCount: planned.length,
completedCount: planChannels.filter((c) => c.status === "completed").length,
totalPlannedCost: planned.reduce((sum, c) => sum + (c.planned_cost || 0), 0),
};
}, [planChannels]);
if (loading) {
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Загрузка..." }]} showProjectSelector={false} />
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Планы закупок", href: `/dashboard/${workspaceId}/purchase-plans` },
{ label: project?.title || "План закупок" },
]}
showProjectSelector={false}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/dashboard/${workspaceId}/purchase-plans`)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight">
План закупок: {project?.title}
</h1>
<p className="text-muted-foreground">
{stats.totalChannels} каналов Планируемый бюджет:{" "}
{formatCurrency(stats.totalPlannedCost)}
</p>
</div>
</div>
{canWrite && (
<div className="flex gap-2">
<Button variant="outline" onClick={loadPlanChannels}>
<RefreshCw className="h-4 w-4 mr-2" />
Обновить
</Button>
<Button onClick={handleOpenAddDialog}>
<Plus className="h-4 w-4 mr-2" />
Добавить канал
</Button>
</div>
)}
</div>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Всего каналов
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalChannels}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Запланировано
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.plannedCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Завершено
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.completedCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Планируемый бюджет
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(stats.totalPlannedCost)}
</div>
</CardContent>
</Card>
</div>
{/* Table */}
{planChannels.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<ShoppingCart 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">
Добавьте каналы для размещения рекламы
</p>
{canWrite && (
<Button onClick={handleOpenAddDialog}>
<Plus className="h-4 w-4 mr-2" />
Добавить канал
</Button>
)}
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("channel")}
>
Канал
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
</TableHead>
<TableHead>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("status")}
>
Статус
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
</TableHead>
<TableHead className="text-right">
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8"
onClick={() => handleSort("cost")}
>
План. стоимость
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
</TableHead>
<TableHead>Комментарий</TableHead>
<TableHead className="w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedChannels.map((planChannel) => (
<TableRow key={planChannel.id}>
<TableCell>
<div>
<div className="font-medium">{planChannel.channel.title}</div>
{planChannel.channel.username && (
<a
href={`https://t.me/${planChannel.channel.username}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
@{planChannel.channel.username}
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
</TableCell>
<TableCell>{getStatusBadge(planChannel.status)}</TableCell>
<TableCell className="text-right">
{formatCurrency(planChannel.planned_cost)}
</TableCell>
<TableCell className="max-w-[200px] truncate">
{planChannel.comment || "—"}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link
href={`/dashboard/${workspaceId}/placements/new?channel_id=${planChannel.channel.id}`}
>
<ShoppingCart className="h-4 w-4 mr-2" />
Создать размещение
</Link>
</DropdownMenuItem>
{canWrite && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleOpenEditDialog(planChannel)}
>
<Pencil className="h-4 w-4 mr-2" />
Редактировать
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeletingChannel(planChannel)}
className="text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Удалить
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={2} className="font-bold">
Итого запланировано:
</TableCell>
<TableCell className="text-right font-bold">
{formatCurrency(stats.totalPlannedCost)}
</TableCell>
<TableCell colSpan={2}></TableCell>
</TableRow>
</TableFooter>
</Table>
</Card>
)}
</div>
{/* Add Channel Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Добавить канал в план</DialogTitle>
<DialogDescription>
Выберите канал из каталога для добавления в план закупок
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Канал</Label>
{loadingChannels ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Загрузка каналов...
</div>
) : (
<Select value={selectedChannelId} onValueChange={setSelectedChannelId}>
<SelectTrigger>
<SelectValue placeholder="Выберите канал" />
</SelectTrigger>
<SelectContent>
{availableChannels.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground text-center">
Все каналы уже добавлены
</div>
) : (
availableChannels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
<div className="flex flex-col">
<span>{channel.title}</span>
{channel.username && (
<span className="text-xs text-muted-foreground">
@{channel.username}
</span>
)}
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-2">
<Label htmlFor="planned-cost">Планируемая стоимость ()</Label>
<Input
id="planned-cost"
type="number"
placeholder="0"
value={plannedCost}
onChange={(e) => setPlannedCost(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="comment">Комментарий</Label>
<Textarea
id="comment"
placeholder="Дополнительная информация..."
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={2}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAddDialog(false)}>
Отмена
</Button>
<Button
onClick={handleAddChannel}
disabled={!selectedChannelId || isAdding}
>
{isAdding ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Добавление...
</>
) : (
"Добавить"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Dialog */}
<Dialog open={!!editingChannel} onOpenChange={(open) => !open && setEditingChannel(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Редактировать канал</DialogTitle>
<DialogDescription>
{editingChannel?.channel.title}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-planned-cost">Планируемая стоимость ()</Label>
<Input
id="edit-planned-cost"
type="number"
placeholder="0"
value={editPlannedCost}
onChange={(e) => setEditPlannedCost(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-comment">Комментарий</Label>
<Textarea
id="edit-comment"
placeholder="Дополнительная информация..."
value={editComment}
onChange={(e) => setEditComment(e.target.value)}
rows={2}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingChannel(null)}>
Отмена
</Button>
<Button onClick={handleUpdateChannel} disabled={isUpdating}>
{isUpdating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Сохранение...
</>
) : (
"Сохранить"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog
open={!!deletingChannel}
onOpenChange={(open) => !open && setDeletingChannel(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить канал из плана?</AlertDialogTitle>
<AlertDialogDescription>
Канал "{deletingChannel?.channel.title}" будет удалён из плана закупок.
Это действие нельзя отменить.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteChannel}>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,234 @@
"use client";
// ============================================================================
// Purchase Plans Overview Page
// ============================================================================
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import {
Loader2,
ClipboardList,
ChevronRight,
Tv,
Calendar,
DollarSign,
CheckCircle,
Clock,
XCircle,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwarePurchasePlanApi } from "@/lib/demo";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { Project, PurchasePlanChannel } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatCurrency = (value: number | null) => {
if (value === null) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
interface ProjectPlanStats {
totalChannels: number;
plannedChannels: number;
completedChannels: number;
cancelledChannels: number;
totalPlannedCost: number;
}
// ============================================================================
// Component
// ============================================================================
export default function PurchasePlansPage() {
const params = useParams();
const router = useRouter();
const workspaceId = params?.workspaceId as string;
const { projects, isLoadingProjects } = useWorkspace();
const [projectStats, setProjectStats] = useState<Record<string, ProjectPlanStats>>({});
const [loadingStats, setLoadingStats] = useState(true);
useEffect(() => {
if (projects.length > 0) {
loadAllStats();
} else {
setLoadingStats(false);
}
}, [projects, workspaceId]);
const loadAllStats = async () => {
setLoadingStats(true);
const stats: Record<string, ProjectPlanStats> = {};
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),
};
} 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,
};
}
}
setProjectStats(stats);
setLoadingStats(false);
};
const isLoading = isLoadingProjects || loadingStats;
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Планы закупок" },
]}
showProjectSelector={false}
/>
<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">
Управление планами закупок по проектам
</p>
</div>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : 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" />
<h3 className="text-lg font-semibold mb-2">Нет проектов</h3>
<p className="text-muted-foreground text-center mb-4">
Добавьте проект через Telegram бота для создания плана закупок
</p>
<Button asChild>
<Link href={`/dashboard/${workspaceId}/projects`}>
Перейти к проектам
</Link>
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => {
const stats = projectStats[project.id];
return (
<Card
key={project.id}
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() =>
router.push(`/dashboard/${workspaceId}/purchase-plans/${project.id}`)
}
>
<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>
<div>
<CardTitle className="text-lg">{project.title}</CardTitle>
{project.username && (
<CardDescription>@{project.username}</CardDescription>
)}
</div>
</div>
<ChevronRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
{stats ? (
<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>
)}
</div>
) : (
<div className="text-sm text-muted-foreground">Загрузка...</div>
)}
</CardContent>
</Card>
);
})}
</div>
)}
</div>
</>
);
}