"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 ( Запланировано ); case "completed": return ( Завершено ); case "cancelled": return ( Отменено ); default: return {status}; } }; 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([]); const [loading, setLoading] = useState(true); const [project, setProject] = useState(null); // Add channel dialog const [showAddDialog, setShowAddDialog] = useState(false); const [availableChannels, setAvailableChannels] = useState([]); 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(null); const [editPlannedCost, setEditPlannedCost] = useState(""); const [editComment, setEditComment] = useState(""); const [isUpdating, setIsUpdating] = useState(false); // Delete confirmation const [deletingChannel, setDeletingChannel] = useState(null); // Sorting const [sortField, setSortField] = useState(null); const [sortOrder, setSortOrder] = useState(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 ( <>
); } return ( <>
{/* Header */}

План закупок: {project?.title}

{stats.totalChannels} каналов • Планируемый бюджет:{" "} {formatCurrency(stats.totalPlannedCost)}

{canWrite && (
)}
{/* Stats Cards */}
Всего каналов
{stats.totalChannels}
Запланировано
{stats.plannedCount}
Завершено
{stats.completedCount}
Планируемый бюджет
{formatCurrency(stats.totalPlannedCost)}
{/* Table */} {planChannels.length === 0 ? (

План пуст

Добавьте каналы для размещения рекламы

{canWrite && ( )}
) : ( Комментарий {sortedChannels.map((planChannel) => (
{planChannel.channel.title}
{planChannel.channel.username && ( e.stopPropagation()} > @{planChannel.channel.username} )}
{getStatusBadge(planChannel.status)} {formatCurrency(planChannel.planned_cost)} {planChannel.comment || "—"} Создать размещение {canWrite && ( <> handleOpenEditDialog(planChannel)} > Редактировать setDeletingChannel(planChannel)} className="text-destructive" > Удалить )}
))}
Итого запланировано: {formatCurrency(stats.totalPlannedCost)}
)}
{/* Add Channel Dialog */} Добавить канал в план Выберите канал из каталога для добавления в план закупок
{loadingChannels ? (
Загрузка каналов...
) : ( )}
setPlannedCost(e.target.value)} />