Files
2026-02-10 17:15:38 +03:00

1059 lines
41 KiB
TypeScript
Raw Permalink 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, useCallback, useRef, 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,
RefreshCw,
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 { Checkbox } from "@/components/ui/checkbox";
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 { toast } from "sonner";
import { handleApiError, showPermissionErrorToast } from "@/lib/utils/error-handler";
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 { currentWorkspace, workspaces, isAdmin, isWorkspaceAdmin, hasPermission, isDemoMode } = useWorkspace();
// Local state for projects with proper include_archived support
const [localProjects, setLocalProjects] = useState<Project[]>([]);
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
const [localProjectsWithStats, 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 [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteConfirmationText, setDeleteConfirmationText] = useState("");
// Move project state
const [showMoveDialog, setShowMoveDialog] = useState(false);
const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
const [targetWorkspaceAdmin, setTargetWorkspaceAdmin] = useState(false);
const [isCheckingTargetPermissions, setIsCheckingTargetPermissions] = useState(false);
// Show archived projects toggle - must be defined before loadProjects
const [showArchived, setShowArchived] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
// Load projects with include_archived support
const loadProjects = useCallback(async () => {
try {
setIsLoadingProjects(true);
if (isDemoMode) {
const demoProjectsRaw = (await import("@/lib/demo")).demoProjects;
const projects = showArchived
? demoProjectsRaw
: demoProjectsRaw.filter(p => p.status === "active");
setLocalProjects(projects);
} else {
const response = await projectsApi.list(workspaceId, { size: 100, include_archived: showArchived });
setLocalProjects(response.items);
}
} catch (err) {
console.error("Failed to load projects:", err);
} finally {
setIsLoadingProjects(false);
}
}, [workspaceId, showArchived, isDemoMode]);
// Manual refresh - checks for new projects
const handleRefresh = async () => {
setIsRefreshing(true);
await loadProjects();
setIsRefreshing(false);
};
// Initial load and when showArchived changes
useEffect(() => {
loadProjects();
}, [loadProjects]);
const handleOpenSettings = (project: Project) => {
setSettingsProject(project);
setInviteLinkType(project.purchase_invite_type_default || "public");
};
const handleSaveSettings = async () => {
if (!settingsProject) return;
await toast.promise(
(async () => {
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.updateInviteLinkType(workspaceId, settingsProject.id, {
purchase_invite_type_default: inviteLinkType,
});
await loadProjects();
})(),
{
loading: "Сохранение настроек...",
success: () => {
setSettingsProject(null);
return "Настройки сохранены";
},
error: (err) => {
handleApiError(err);
return "Не удалось сохранить настройки";
},
}
);
};
const handleArchive = async () => {
if (!settingsProject) return;
await toast.promise(
(async () => {
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.archive(workspaceId, settingsProject.id);
await loadProjects();
})(),
{
loading: "Архивирование проекта...",
success: () => {
setSettingsProject(null);
return `Проект «${settingsProject.title}» архивирован`;
},
error: (err) => {
handleApiError(err);
return "Не удалось архивировать проект";
},
}
);
};
const handleUnarchive = async () => {
if (!settingsProject) return;
await toast.promise(
(async () => {
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.unarchive(workspaceId, settingsProject.id);
await loadProjects();
})(),
{
loading: "Восстановление проекта...",
success: () => {
setSettingsProject(null);
return `Проект «${settingsProject.title}» восстановлен`;
},
error: (err) => {
handleApiError(err);
return "Не удалось восстановить проект";
},
}
);
};
const handleDelete = async () => {
if (!settingsProject) return;
await toast.promise(
(async () => {
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.delete(workspaceId, settingsProject.id);
await loadProjects();
})(),
{
loading: "Удаление проекта...",
success: () => {
setSettingsProject(null);
setShowDeleteDialog(false);
setDeleteConfirmationText("");
return `Проект «${settingsProject.title}» удалён`;
},
error: (err) => {
handleApiError(err);
return "Не удалось удалить проект";
},
}
);
};
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;
const targetWorkspace = workspaces.find(w => w.id === targetWorkspaceId);
await toast.promise(
(async () => {
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.move(workspaceId, settingsProject.id, { target_workspace_id: targetWorkspaceId });
await loadProjects();
})(),
{
loading: "Перемещение проекта...",
success: () => {
setSettingsProject(null);
setShowMoveDialog(false);
setTargetWorkspaceId("");
setTargetWorkspaceAdmin(false);
return `Проект перемещён в «${targetWorkspace?.name}»`;
},
error: (err) => {
handleApiError(err);
return "Не удалось переместить проект";
},
}
);
};
const getChannelAvatar = (project: Project) => {
if (project.username) {
return `https://t.me/i/userpic/320/${project.username}.jpg`;
}
return null;
};
// Check if user has any analytics permission
const canViewAnalytics = hasPermission("analytics_read") || hasPermission("analytics_without_clicks") || hasPermission("analytics_own_creatives");
// Check if user has subscriptions visibility
const canViewSubscriptions = hasPermission("analytics_read") || hasPermission("analytics_own_creatives");
// Check if user can edit projects
const canEditProjects = hasPermission("projects_write");
// Load statistics for each project
useEffect(() => {
if (localProjects.length === 0) {
setProjectsWithStats([]);
return;
}
if (!canViewAnalytics) {
setProjectsWithStats(localProjects.map(p => ({ ...p, stats: undefined })));
setLoadingStats(false);
return;
}
const loadStats = async () => {
setLoadingStats(true);
try {
const statsPromises = localProjects.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(localProjects);
} finally {
setLoadingStats(false);
}
};
loadStats();
}, [localProjects, workspaceId, canViewAnalytics]);
const isLoading = isLoadingProjects || loadingStats;
// Filter projects based on archived status (already filtered by API, but keep for safety)
const filteredProjects = localProjectsWithStats.filter((project) => {
if (showArchived) return true;
return project.status !== "archived";
});
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>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="show-archived"
checked={showArchived}
onCheckedChange={(checked) => setShowArchived(checked === true)}
/>
<Label htmlFor="show-archived" className="text-sm cursor-pointer">
Показать архивированные
</Label>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isLoadingProjects || isRefreshing}
>
<RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} />
Обновить
</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : filteredProjects.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>
{filteredProjects.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)}
disabled={!canEditProjects}
>
<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">
{filteredProjects.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)}
disabled={!canEditProjects}
>
<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={!canEditProjects || isDemoMode}
className="w-full justify-start"
>
<RotateCcw className="h-4 w-4 mr-2" />
Восстановить из архива
</Button>
) : (
<Button
variant="outline"
onClick={handleArchive}
disabled={!canEditProjects || isDemoMode}
className="w-full justify-start"
>
<Archive className="h-4 w-4 mr-2" />
Архивировать проект
</Button>
)}
<Button
variant="outline"
onClick={handleOpenMoveDialog}
disabled={!isAdmin || !canEditProjects || isDemoMode}
className="w-full justify-start"
title={!isAdmin ? "Вы должны быть администратором для перемещения проекта" : !canEditProjects ? "Нет прав на редактирование проектов" : undefined}
>
<ArrowRightFromLine className="h-4 w-4 mr-2" />
Переместить в другой воркспейс
</Button>
<Button
variant="destructive"
onClick={handleOpenDeleteDialog}
disabled={!canEditProjects || isDemoMode || 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={!canEditProjects || isDemoMode}
>
Сохранить
</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={deleteConfirmationText !== `Удалить ${settingsProject?.title}` || isDemoMode}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Удалить
</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 || isDemoMode}
>
<ArrowRightFromLine className="h-4 w-4 mr-2" />
Переместить
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</TooltipProvider>
);
}