Files
tgex-frontend/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx
2026-01-21 10:36:08 +03:00

742 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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: "Загрузка..." }]} />
<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 || "План закупов" },
]}
/>
<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>
</>
);
}