235 lines
8.6 KiB
TypeScript
235 lines
8.6 KiB
TypeScript
"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>
|
||
</>
|
||
);
|
||
}
|
||
|