Files
tgex-frontend/app/dashboard/[workspaceId]/projects/page.tsx
2026-01-21 22:43:54 +03:00

982 lines
38 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";
// ============================================================================
// Projects Page (Target Channels) - Enhanced with Statistics
// ============================================================================
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import {
Loader2,
Tv,
ExternalLink,
Grid,
List,
TrendingUp,
Users,
Eye,
Info,
Bot,
Settings,
FolderKanban,
Archive,
RotateCcw,
Trash2,
Zap,
Target,
CircleDollarSign,
CircleQuestionMark,
ArrowRightFromLine,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwareAnalyticsApi, demoAwareProjectsApi } from "@/lib/demo";
import { projectsApi } from "@/lib/api";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { BOT_USERNAME } from "@/lib/utils/constants";
import type { Project, SpendingAnalytics } from "@/lib/types/api";
// ============================================================================
// Types
// ============================================================================
interface ProjectWithStats extends Project {
stats?: {
total_cost: number;
total_subscriptions: number;
total_views: number;
avg_cpf: number | null;
avg_cpm: number | null;
placements_count: number;
};
}
// ============================================================================
// Helpers
// ============================================================================
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",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatStatus = (status: string): string => {
switch (status) {
case "active":
return "Активен";
case "inactive":
return "Неактивен";
case "archived":
return "В архиве";
default:
return status;
}
};
// ============================================================================
// Component
// ============================================================================
export default function ProjectsPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { projects, isLoadingProjects, currentWorkspace, refreshProjects, workspaces, isAdmin, isWorkspaceAdmin } = useWorkspace();
const [projectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([]);
const [loadingStats, setLoadingStats] = useState(false);
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
const [settingsProject, setSettingsProject] = useState<Project | null>(null);
const [inviteLinkType, setInviteLinkType] = useState<"public" | "approval">("public");
const [isUpdatingSettings, setIsUpdatingSettings] = useState(false);
const [isArchiving, setIsArchiving] = useState(false);
const [isUnarchiving, setIsUnarchiving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteConfirmationText, setDeleteConfirmationText] = useState("");
// Move project state
const [showMoveDialog, setShowMoveDialog] = useState(false);
const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
const [isMoving, setIsMoving] = useState(false);
const [targetWorkspaceAdmin, setTargetWorkspaceAdmin] = useState(false);
const [isCheckingTargetPermissions, setIsCheckingTargetPermissions] = useState(false);
const { isDemoMode } = useWorkspace();
const handleOpenSettings = (project: Project) => {
setSettingsProject(project);
setInviteLinkType(project.purchase_invite_type_default || "public");
};
const handleSaveSettings = async () => {
if (!settingsProject) return;
try {
setIsUpdatingSettings(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.updateInviteLinkType(workspaceId, settingsProject.id, {
purchase_invite_type_default: inviteLinkType,
});
await refreshProjects();
setSettingsProject(null);
} catch (err) {
console.error("Failed to update settings:", err);
} finally {
setIsUpdatingSettings(false);
}
};
const handleArchive = async () => {
if (!settingsProject) return;
try {
setIsArchiving(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.archive(workspaceId, settingsProject.id);
await refreshProjects();
setSettingsProject(null);
} catch (err) {
console.error("Failed to archive project:", err);
} finally {
setIsArchiving(false);
}
};
const handleUnarchive = async () => {
if (!settingsProject) return;
try {
setIsUnarchiving(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.unarchive(workspaceId, settingsProject.id);
await refreshProjects();
setSettingsProject(null);
} catch (err) {
console.error("Failed to unarchive project:", err);
} finally {
setIsUnarchiving(false);
}
};
const handleDelete = async () => {
if (!settingsProject) return;
try {
setIsDeleting(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.delete(workspaceId, settingsProject.id);
await refreshProjects();
setSettingsProject(null);
setShowDeleteDialog(false);
setDeleteConfirmationText("");
} catch (err) {
console.error("Failed to delete project:", err);
} finally {
setIsDeleting(false);
}
};
const handleOpenDeleteDialog = () => {
setShowDeleteDialog(true);
};
const handleCloseDeleteDialog = () => {
setShowDeleteDialog(false);
setDeleteConfirmationText("");
};
const handleOpenMoveDialog = async () => {
setShowMoveDialog(true);
setTargetWorkspaceId("");
setTargetWorkspaceAdmin(false);
// Check permissions for all other workspaces
setIsCheckingTargetPermissions(true);
const adminWorkspaces: string[] = [];
for (const ws of workspaces) {
if (ws.id === workspaceId) continue;
const isAdmin = await isWorkspaceAdmin(ws.id);
if (isAdmin) {
adminWorkspaces.push(ws.id);
}
}
// Set first admin workspace as default if available
if (adminWorkspaces.length > 0) {
setTargetWorkspaceId(adminWorkspaces[0]);
setTargetWorkspaceAdmin(true);
}
setIsCheckingTargetPermissions(false);
};
const handleCloseMoveDialog = () => {
setShowMoveDialog(false);
setTargetWorkspaceId("");
setTargetWorkspaceAdmin(false);
};
const handleTargetWorkspaceChange = async (newWorkspaceId: string) => {
setTargetWorkspaceId(newWorkspaceId);
setTargetWorkspaceAdmin(false);
// Check if user is admin in target workspace
const isAdmin = await isWorkspaceAdmin(newWorkspaceId);
setTargetWorkspaceAdmin(isAdmin);
};
const handleMoveProject = async () => {
if (!settingsProject || !targetWorkspaceId || !targetWorkspaceAdmin) return;
try {
setIsMoving(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.move(workspaceId, settingsProject.id, { target_workspace_id: targetWorkspaceId });
await refreshProjects();
setSettingsProject(null);
setShowMoveDialog(false);
setTargetWorkspaceId("");
setTargetWorkspaceAdmin(false);
} catch (err) {
console.error("Failed to move project:", err);
} finally {
setIsMoving(false);
}
};
const getChannelAvatar = (project: Project) => {
if (project.username) {
return `https://t.me/i/userpic/320/${project.username}.jpg`;
}
return null;
};
// Load statistics for each project
useEffect(() => {
if (projects.length === 0) {
setProjectsWithStats([]);
return;
}
const loadStats = async () => {
setLoadingStats(true);
try {
const statsPromises = projects.map(async (project) => {
try {
const spending = await demoAwareAnalyticsApi.spending(workspaceId, {
project_id: project.id,
});
return {
...project,
stats: {
total_cost: spending.total_cost,
total_subscriptions: spending.total_subscriptions,
total_views: spending.total_views,
avg_cpf:
spending.total_subscriptions > 0
? spending.total_cost / spending.total_subscriptions
: null,
avg_cpm:
spending.total_views > 0
? (spending.total_cost / spending.total_views) * 1000
: null,
placements_count: spending.placements_count || 0,
},
};
} catch {
return { ...project, stats: undefined };
}
});
const results = await Promise.all(statsPromises);
setProjectsWithStats(results);
} catch (err) {
console.error("Failed to load project stats:", err);
setProjectsWithStats(projects);
} finally {
setLoadingStats(false);
}
};
loadStats();
}, [projects, workspaceId]);
const isLoading = isLoadingProjects || loadingStats;
return (
<TooltipProvider>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${currentWorkspace?.id}` },
{ 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>
<p className="text-muted-foreground">
Telegram каналы, которые вы продвигаете
</p>
</div>
<div className="flex items-center gap-2 border rounded-lg p-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
aria-label="Плиточный вид"
>
<Grid className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Плиточный вид</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={viewMode === "table" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
aria-label="Табличный вид"
>
<List className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Табличный вид</TooltipContent>
</Tooltip>
</div>
</div>
<Alert>
<CircleQuestionMark className="h-4 w-4 mt-1.5" />
<AlertDescription className="flex items-center justify-between flex-wrap gap-3">
<span>
Чтобы добавить новый проект, добавьте бота{" "}
<a
href={`https://t.me/${BOT_USERNAME}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-primary hover:underline inline-flex items-center gap-1"
>
@{BOT_USERNAME}
<ExternalLink className="h-3 w-3" />
</a>{" "}
администратором в ваш Telegram канал.
</span>
<Button
asChild
size="sm"
className="shrink-0"
>
<a
href={`tg://resolve?domain=${BOT_USERNAME}&startchannel&admin=invite_users+post_messages+edit_messages+delete_messages`}
target="_blank"
rel="noopener noreferrer"
>
<Bot className="h-4 w-4 mr-2" />
Добавить бота
</a>
</Button>
</AlertDescription>
</Alert>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : projectsWithStats.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<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 max-w-md">
Добавьте бота администратором в ваш Telegram канал, и он
автоматически появится здесь как проект.
</p>
</CardContent>
</Card>
) : viewMode === "table" ? (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Канал</TableHead>
<TableHead>Статус</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Подписчиков
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Количество подписчиков в канале
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Привлечено
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Привлеченные подписчики по пригласительным ссылкам
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Охват
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Суммарные просмотры рекламных постов
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">CPF</TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projectsWithStats.map((project) => (
<TableRow key={project.id}>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarImage src={getChannelAvatar(project) || undefined} alt={project.title} />
<AvatarFallback className="rounded-lg bg-primary/10">
<FolderKanban className="h-4 w-4 text-primary" />
</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{project.title}</div>
{project.username && (
<a
href={`https://t.me/${project.username}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:underline inline-flex items-center gap-0.5"
>
@{project.username}
<ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</div>
</div>
</TableCell>
<TableCell>
<Badge
variant={project.status === "active" ? "default" : "secondary"}
className={cn(
project.status === "archived" && "bg-muted text-muted-foreground"
)}
>
{formatStatus(project.status)}
</Badge>
</TableCell>
<TableCell className="text-right font-medium">
{formatNumber(project.subscribers_count)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<span className="font-medium">
{formatNumber(project.stats?.total_subscriptions)}
</span>
{project.stats?.placements_count ? (
<span className="text-xs text-muted-foreground">
({project.stats.placements_count} размещ.)
</span>
) : null}
</div>
</TableCell>
<TableCell className="text-right">
{formatNumber(project.stats?.total_views)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<CircleDollarSign className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">
{formatCurrency(project.stats?.avg_cpf)}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex gap-1 flex-wrap">
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/${currentWorkspace?.id}/purchase-plans/${project.id}`}
>
<Zap className="h-3 w-3 mr-1" />
Размещения
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/${currentWorkspace?.id}/creatives?project_id=${project.id}`}
>
<Target className="h-3 w-3 mr-1" />
Креативы
</Link>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenSettings(project)}
>
<Settings className="h-3 w-3" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projectsWithStats.map((project) => (
<Card key={project.id} className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<Avatar className="h-10 w-10 shrink-0">
<AvatarImage src={getChannelAvatar(project) || undefined} alt={project.title} />
<AvatarFallback className="rounded-lg bg-primary/10">
<FolderKanban className="h-5 w-5 text-primary" />
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<CardTitle className="text-base truncate">{project.title}</CardTitle>
{project.username && (
<CardDescription>
<a
href={`https://t.me/${project.username}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline inline-flex items-center gap-0.5 truncate"
>
@{project.username}
<ExternalLink className="h-2.5 w-2.5 shrink-0" />
</a>
</CardDescription>
)}
</div>
</div>
<Badge
variant={project.status === "active" ? "default" : "secondary"}
className={cn(
"shrink-0",
project.status === "archived" && "bg-muted text-muted-foreground"
)}
>
{formatStatus(project.status)}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="h-4 w-4" />
<span>Подписчиков</span>
</div>
<span className="font-semibold">
{formatNumber(project.subscribers_count)}
</span>
</div>
{project.stats && (
<>
<div className="grid grid-cols-2 gap-3 pt-2 border-t">
<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 text-sm">
{formatNumber(project.stats.total_subscriptions)}
</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">
<Eye className="h-4 w-4 text-primary" />
</div>
<div>
<div className="text-xs text-muted-foreground">Охват</div>
<div className="font-semibold text-sm">
{formatNumber(project.stats.total_views)}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center h-8 w-8 rounded-lg bg-primary/10">
<CircleDollarSign className="h-4 w-4 text-primary" />
</div>
<div>
<div className="text-xs text-muted-foreground">CPF</div>
<div className="font-semibold">
{formatCurrency(project.stats.avg_cpf)}
</div>
</div>
</div>
{project.stats.placements_count > 0 && (
<Badge variant="outline" className="text-xs">
{project.stats.placements_count} размещ.
</Badge>
)}
</div>
</>
)}
<div className="flex gap-2 pt-2 border-t">
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link
href={`/dashboard/${currentWorkspace?.id}/purchase-plans/${project.id}`}
>
<Zap className="h-3.5 w-3.5 mr-1.5" />
Размещения
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/${currentWorkspace?.id}/creatives?project_id=${project.id}`}
>
<Target className="h-3.5 w-3.5" />
</Link>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenSettings(project)}
>
<Settings className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
<Dialog open={!!settingsProject} onOpenChange={(open) => !open && setSettingsProject(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Настройки проекта</DialogTitle>
<DialogDescription>
{settingsProject?.title}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="invite-link-type">Тип ссылки по умолчанию</Label>
<Select
value={inviteLinkType}
onValueChange={(value) => setInviteLinkType(value as "public" | "approval")}
>
<SelectTrigger id="invite-link-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="public">Открытая</SelectItem>
<SelectItem value="approval">С заявками</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Тип пригласительной ссылки по умолчанию при создании размещений
</p>
</div>
<div className="space-y-3 pt-4 border-t">
<h4 className="text-sm font-semibold">Действия</h4>
<div className="flex flex-col gap-2">
{settingsProject?.status === "archived" ? (
<Button
variant="outline"
onClick={handleUnarchive}
disabled={isUnarchiving || isDeleting || isUpdatingSettings}
className="w-full justify-start"
>
{isUnarchiving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Восстановление...
</>
) : (
<>
<RotateCcw className="h-4 w-4 mr-2" />
Восстановить из архива
</>
)}
</Button>
) : (
<Button
variant="outline"
onClick={handleArchive}
disabled={isArchiving || isDeleting || isUpdatingSettings}
className="w-full justify-start"
>
{isArchiving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Архивирование...
</>
) : (
<>
<Archive className="h-4 w-4 mr-2" />
Архивировать проект
</>
)}
</Button>
)}
<Button
variant="outline"
onClick={handleOpenMoveDialog}
disabled={!isAdmin || isArchiving || isDeleting || isUpdatingSettings || isUnarchiving}
className="w-full justify-start"
title={!isAdmin ? "Вы должны быть администратором для перемещения проекта" : undefined}
>
<ArrowRightFromLine className="h-4 w-4 mr-2" />
Переместить в другой воркспейс
</Button>
<Button
variant="destructive"
onClick={handleOpenDeleteDialog}
disabled={isArchiving || isDeleting || isUpdatingSettings || settingsProject?.status === "archived"}
className="w-full justify-start"
>
<Trash2 className="h-4 w-4 mr-2" />
Удалить проект
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setSettingsProject(null)}
>
Закрыть
</Button>
<Button
onClick={handleSaveSettings}
disabled={isUpdatingSettings || isArchiving || isDeleting || isUnarchiving}
>
{isUpdatingSettings ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Сохранение...
</>
) : (
"Сохранить"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={handleCloseDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить проект "{settingsProject?.title}"?</AlertDialogTitle>
<AlertDialogDescription>
Это действие необратимо. Все данные проекта будут удалены, включая креативы, размещения и аналитику.
<br /><br />
Для подтверждения введите <strong>Удалить {settingsProject?.title}</strong> ниже:
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-4 py-4">
<Input
placeholder={`Удалить ${settingsProject?.title}`}
value={deleteConfirmationText}
onChange={(e) => setDeleteConfirmationText(e.target.value)}
className="mt-4"
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCloseDeleteDialog}>
Отмена
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting || deleteConfirmationText !== `Удалить ${settingsProject?.title}`}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Удаление...
</>
) : (
"Удалить"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Move Project Dialog */}
<Dialog open={showMoveDialog} onOpenChange={handleCloseMoveDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Переместить проект</DialogTitle>
<DialogDescription>
{settingsProject?.title}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{isCheckingTargetPermissions ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="target-workspace">Воркспейс назначения</Label>
<Select
value={targetWorkspaceId}
onValueChange={handleTargetWorkspaceChange}
>
<SelectTrigger id="target-workspace">
<SelectValue placeholder="Выберите воркспейс" />
</SelectTrigger>
<SelectContent>
{workspaces
.filter((ws) => ws.id !== workspaceId)
.map((ws) => (
<SelectItem key={ws.id} value={ws.id}>
{ws.name}
</SelectItem>
))}
</SelectContent>
</Select>
{!targetWorkspaceId && (
<p className="text-xs text-muted-foreground">
Выберите воркспейс для перемещения проекта
</p>
)}
</div>
{targetWorkspaceId && !targetWorkspaceAdmin && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
У вас нет прав администратора в этом воркспейсе. Для перемещения проекта вы должны быть администратором в обоих воркспейсах.
</div>
)}
{targetWorkspaceId && workspaces.find(w => w.id === targetWorkspaceId)?.avatar_url && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Проект будет перемещён в воркспейс «{workspaces.find(w => w.id === targetWorkspaceId)?.name}»</span>
</div>
)}
</>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleCloseMoveDialog}
>
Отмена
</Button>
<Button
onClick={handleMoveProject}
disabled={!targetWorkspaceId || !targetWorkspaceAdmin || isMoving || isCheckingTargetPermissions}
>
{isMoving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Перемещение...
</>
) : (
<>
<ArrowRightFromLine className="h-4 w-4 mr-2" />
Переместить
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</TooltipProvider>
);
}