feat: global ui & functional update
This commit is contained in:
@@ -16,13 +16,18 @@ import {
|
||||
TrendingUp,
|
||||
Users,
|
||||
Eye,
|
||||
ShoppingCart,
|
||||
Info,
|
||||
Bot,
|
||||
Settings,
|
||||
Folder,
|
||||
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";
|
||||
@@ -80,6 +85,7 @@ import {
|
||||
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";
|
||||
|
||||
@@ -93,6 +99,8 @@ interface ProjectWithStats extends Project {
|
||||
total_subscriptions: number;
|
||||
total_views: number;
|
||||
avg_cpf: number | null;
|
||||
avg_cpm: number | null;
|
||||
placements_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,6 +123,19 @@ const formatCurrency = (value: number | null | undefined): string => {
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatStatus = (status: string): string => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "Активен";
|
||||
case "inactive":
|
||||
return "Неактивен";
|
||||
case "archived":
|
||||
return "В архиве";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
@@ -122,7 +143,7 @@ const formatCurrency = (value: number | null | undefined): string => {
|
||||
export default function ProjectsPage() {
|
||||
const params = useParams();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const { projects, isLoadingProjects, currentWorkspace, refreshProjects } = useWorkspace();
|
||||
const { projects, isLoadingProjects, currentWorkspace, refreshProjects, workspaces, isAdmin, isWorkspaceAdmin } = useWorkspace();
|
||||
|
||||
const [projectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([]);
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
@@ -131,9 +152,18 @@ export default function ProjectsPage() {
|
||||
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) => {
|
||||
@@ -175,6 +205,22 @@ export default function ProjectsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -202,8 +248,67 @@ export default function ProjectsPage() {
|
||||
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) => {
|
||||
// Telegram channel avatar URL format
|
||||
if (project.username) {
|
||||
return `https://t.me/i/userpic/320/${project.username}.jpg`;
|
||||
}
|
||||
@@ -235,6 +340,11 @@ export default function ProjectsPage() {
|
||||
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 {
|
||||
@@ -275,27 +385,37 @@ export default function ProjectsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border rounded-lg p-1">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("grid")}
|
||||
aria-label="Плиточный вид"
|
||||
>
|
||||
<Grid className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "table" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("table")}
|
||||
aria-label="Табличный вид"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<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>
|
||||
<Tv className="h-4 w-4" />
|
||||
<CircleQuestionMark className="h-4 w-4 mt-1.5" />
|
||||
<AlertDescription className="flex items-center justify-between flex-wrap gap-3">
|
||||
<span>
|
||||
Чтобы добавить новый проект, добавьте бота{" "}
|
||||
@@ -334,7 +454,7 @@ export default function ProjectsPage() {
|
||||
) : projectsWithStats.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Tv className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<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 канал, и он
|
||||
@@ -343,7 +463,6 @@ export default function ProjectsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : viewMode === "table" ? (
|
||||
// Table View
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -365,7 +484,7 @@ export default function ProjectsPage() {
|
||||
</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" />
|
||||
@@ -378,7 +497,7 @@ export default function ProjectsPage() {
|
||||
</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" />
|
||||
@@ -389,19 +508,7 @@ export default function ProjectsPage() {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
CPF
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Cost Per Follower — средняя стоимость подписчика
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">CPF</TableHead>
|
||||
<TableHead className="w-[200px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -413,7 +520,7 @@ export default function ProjectsPage() {
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={getChannelAvatar(project) || undefined} alt={project.title} />
|
||||
<AvatarFallback className="rounded-lg bg-primary/10">
|
||||
<Tv className="h-4 w-4 text-primary" />
|
||||
<FolderKanban className="h-4 w-4 text-primary" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
@@ -435,25 +542,38 @@ export default function ProjectsPage() {
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={project.status === "active" ? "default" : "secondary"}
|
||||
className={cn(
|
||||
project.status === "archived" && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{project.status === "active"
|
||||
? "Активен"
|
||||
: project.status === "inactive"
|
||||
? "Неактивен"
|
||||
: "Архив"}
|
||||
{formatStatus(project.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatNumber(project.subscribers_count)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(project.stats?.total_subscriptions)}
|
||||
<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">
|
||||
{formatCurrency(project.stats?.avg_cpf)}
|
||||
<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">
|
||||
@@ -461,14 +581,15 @@ export default function ProjectsPage() {
|
||||
<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}`}
|
||||
>
|
||||
<Folder className="h-3 w-3 mr-1" />
|
||||
<Target className="h-3 w-3 mr-1" />
|
||||
Креативы
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -477,8 +598,7 @@ export default function ProjectsPage() {
|
||||
size="sm"
|
||||
onClick={() => handleOpenSettings(project)}
|
||||
>
|
||||
<Settings className="h-3 w-3 mr-1" />
|
||||
Настройки
|
||||
<Settings className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -488,31 +608,30 @@ export default function ProjectsPage() {
|
||||
</Table>
|
||||
</Card>
|
||||
) : (
|
||||
// Grid View
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projectsWithStats.map((project) => (
|
||||
<Card key={project.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<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">
|
||||
<Tv className="h-5 w-5 text-primary" />
|
||||
<FolderKanban className="h-5 w-5 text-primary" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<CardTitle className="text-base">{project.title}</CardTitle>
|
||||
<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"
|
||||
className="hover:underline inline-flex items-center gap-0.5 truncate"
|
||||
>
|
||||
@{project.username}
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
<ExternalLink className="h-2.5 w-2.5 shrink-0" />
|
||||
</a>
|
||||
</CardDescription>
|
||||
)}
|
||||
@@ -520,85 +639,88 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
<Badge
|
||||
variant={project.status === "active" ? "default" : "secondary"}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
project.status === "archived" && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{project.status === "active"
|
||||
? "Активен"
|
||||
: project.status === "inactive"
|
||||
? "Неактивен"
|
||||
: "Архив"}
|
||||
{formatStatus(project.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{project.subscribers_count !== undefined && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{formatNumber(project.subscribers_count)}
|
||||
<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 className="text-xs text-muted-foreground">
|
||||
Подписчиков
|
||||
<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>
|
||||
)}
|
||||
{project.stats && (
|
||||
<>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{formatNumber(project.stats.total_subscriptions)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Привлечено подписчиков
|
||||
</div>
|
||||
<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="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{formatNumber(project.stats.total_views)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Суммарный охват
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
<div className="text-xs text-muted-foreground">CPF</div>
|
||||
<div className="font-semibold">
|
||||
{formatCurrency(project.stats.avg_cpf)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
CPF
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{project.stats.placements_count > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.stats.placements_count} размещ.
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<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}`}
|
||||
>
|
||||
<Folder className="h-4 w-4 mr-1" />
|
||||
Креативы
|
||||
<Target className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -606,8 +728,7 @@ export default function ProjectsPage() {
|
||||
size="sm"
|
||||
onClick={() => handleOpenSettings(project)}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-1" />
|
||||
Настройки
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -616,7 +737,6 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings Dialog */}
|
||||
<Dialog open={!!settingsProject} onOpenChange={(open) => !open && setSettingsProject(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
@@ -641,45 +761,71 @@ export default function ProjectsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Тип пригласительной ссылки, который будет использоваться по умолчанию при создании размещений для этого проекта
|
||||
Тип пригласительной ссылки по умолчанию при создании размещений
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="space-y-3 pt-4 border-t">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-destructive">Опасная зона</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{settingsProject?.status !== "archived" && (
|
||||
<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>
|
||||
)}
|
||||
<h4 className="text-sm font-semibold">Действия</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{settingsProject?.status === "archived" ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleOpenDeleteDialog}
|
||||
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"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Удалить проект
|
||||
{isArchiving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Архивирование...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
Архивировать проект
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
@@ -688,11 +834,11 @@ export default function ProjectsPage() {
|
||||
variant="outline"
|
||||
onClick={() => setSettingsProject(null)}
|
||||
>
|
||||
Отмена
|
||||
Закрыть
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveSettings}
|
||||
disabled={isUpdatingSettings || isArchiving || isDeleting}
|
||||
disabled={isUpdatingSettings || isArchiving || isDeleting || isUnarchiving}
|
||||
>
|
||||
{isUpdatingSettings ? (
|
||||
<>
|
||||
@@ -707,7 +853,6 @@ export default function ProjectsPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={handleCloseDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -747,6 +892,89 @@ export default function ProjectsPage() {
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user