feat: update permissions

This commit is contained in:
ivannoskov
2026-02-10 17:15:38 +03:00
parent 6941915d76
commit e1117e46c8
13 changed files with 961 additions and 523 deletions

View File

@@ -74,6 +74,8 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { toast } from "sonner";
import { handleApiError } from "@/lib/utils/error-handler";
import { BOT_USERNAME } from "@/lib/utils/constants"; import { BOT_USERNAME } from "@/lib/utils/constants";
import { ProjectSelector } from "@/components/project-selector"; import { ProjectSelector } from "@/components/project-selector";
import type { Creative, CreativeAnalyticsItem } from "@/lib/types/api"; import type { Creative, CreativeAnalyticsItem } from "@/lib/types/api";
@@ -157,17 +159,21 @@ export default function CreativesPage() {
const [includeArchived, setIncludeArchived] = useState(false); const [includeArchived, setIncludeArchived] = useState(false);
const [viewMode, setViewMode] = useState<"table" | "grid">("table"); const [viewMode, setViewMode] = useState<"table" | "grid">("table");
const canWrite = hasPermission("placements_write"); // Permission checks
const canReadCreatives = hasPermission("creatives_read");
const canWriteCreatives = hasPermission("creatives_write");
const canViewAnalytics = hasPermission("analytics_read") || hasPermission("analytics_without_clicks") || hasPermission("analytics_own_creatives");
const canViewSubscriptions = hasPermission("analytics_read") || hasPermission("analytics_own_creatives");
useEffect(() => { useEffect(() => {
loadCreatives(); loadCreatives();
}, [workspaceId, projectFilterId, includeArchived]); }, [workspaceId, projectFilterId, includeArchived]);
useEffect(() => { useEffect(() => {
if (creatives.length > 0) { if (creatives.length > 0 && canViewAnalytics) {
loadAnalytics(); loadAnalytics();
} }
}, [creatives, workspaceId, projectFilterId]); }, [creatives, workspaceId, projectFilterId, canViewAnalytics]);
const loadCreatives = async () => { const loadCreatives = async () => {
try { try {
@@ -179,13 +185,18 @@ export default function CreativesPage() {
}); });
setCreatives(response.items); setCreatives(response.items);
} catch (err) { } catch (err) {
console.error("Failed to load creatives:", err); handleApiError(err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const loadAnalytics = async () => { const loadAnalytics = async () => {
if (!canViewAnalytics) {
setAnalyticsData([]);
return;
}
try { try {
setLoadingAnalytics(true); setLoadingAnalytics(true);
const response = await demoAwareAnalyticsApi.creatives(workspaceId, { const response = await demoAwareAnalyticsApi.creatives(workspaceId, {
@@ -209,24 +220,42 @@ export default function CreativesPage() {
}, [creatives, analyticsData]); }, [creatives, analyticsData]);
const handleArchive = async (creative: Creative) => { const handleArchive = async (creative: Creative) => {
try { await toast.promise(
const newStatus = creative.status === "active" ? "archived" : "active"; (async () => {
await creativesApi.update(workspaceId, creative.id, { status: newStatus }); const newStatus = creative.status === "active" ? "archived" : "active";
loadCreatives(); await creativesApi.update(workspaceId, creative.id, { status: newStatus });
} catch (err) { await loadCreatives();
console.error("Failed to archive creative:", err); })(),
} {
loading: creative.status === "active" ? "Архивирование..." : "Восстановление...",
success: () => {
return creative.status === "active"
? `Креатив «${creative.name}» архивирован`
: `Креатив «${creative.name}» восстановлен`;
},
error: (err) => {
handleApiError(err);
return "Не удалось изменить статус";
},
}
);
}; };
const handleDelete = async (creativeId: string) => { const handleDelete = async (creativeId: string, creativeName: string) => {
if (!confirm("Удалить креатив? Это действие необратимо.")) return; await toast.promise(
(async () => {
try { await creativesApi.delete(workspaceId, creativeId);
await creativesApi.delete(workspaceId, creativeId); await loadCreatives();
loadCreatives(); })(),
} catch (err) { {
console.error("Failed to delete creative:", err); loading: "Удаление креатива...",
} success: () => `Креатив «${creativeName}» удалён`,
error: (err) => {
handleApiError(err);
return "Не удалось удалить креатив";
},
}
);
}; };
@@ -254,14 +283,16 @@ export default function CreativesPage() {
// Totals // Totals
const totals = useMemo(() => { const totals = useMemo(() => {
const data = filteredCreatives.filter((c) => c.analytics); const data = filteredCreatives.filter((c) => canViewAnalytics && c.analytics);
return { return {
placements: data.reduce((sum, c) => sum + (c.analytics?.placements_count || 0), 0), placements: data.reduce((sum, c) => sum + (c.analytics?.placements_count || 0), 0),
cost: data.reduce((sum, c) => sum + (c.analytics?.total_cost || 0), 0), cost: data.reduce((sum, c) => sum + (c.analytics?.total_cost || 0), 0),
subscriptions: data.reduce((sum, c) => sum + (c.analytics?.total_subscriptions || 0), 0), subscriptions: canViewSubscriptions
? data.reduce((sum, c) => sum + (c.analytics?.total_subscriptions || 0), 0)
: 0,
views: data.reduce((sum, c) => sum + (c.analytics?.total_views || 0), 0), views: data.reduce((sum, c) => sum + (c.analytics?.total_views || 0), 0),
}; };
}, [filteredCreatives]); }, [filteredCreatives, canViewAnalytics, canViewSubscriptions]);
return ( return (
<TooltipProvider> <TooltipProvider>
@@ -300,7 +331,7 @@ export default function CreativesPage() {
<Grid className="h-4 w-4" /> <Grid className="h-4 w-4" />
</Button> </Button>
</div> </div>
{canWrite && ( {canWriteCreatives && (
<Button onClick={handleCreateCreativeClick}> <Button onClick={handleCreateCreativeClick}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Создать креатив Создать креатив
@@ -346,7 +377,7 @@ export default function CreativesPage() {
? "Ничего не найдено по вашему запросу" ? "Ничего не найдено по вашему запросу"
: "Создайте первый креатив для начала работы"} : "Создайте первый креатив для начала работы"}
</p> </p>
{canWrite && !search && ( {canWriteCreatives && !search && (
<Button onClick={() => setShowCreateDialog(true)}> <Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Создать креатив Создать креатив
@@ -354,7 +385,7 @@ export default function CreativesPage() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
) : viewMode === "table" ? ( ) : viewMode === "table" ? (
<Card> <Card>
<Table> <Table>
<TableHeader> <TableHeader>
@@ -387,19 +418,21 @@ export default function CreativesPage() {
</Tooltip> </Tooltip>
</div> </div>
</TableHead> </TableHead>
<TableHead className="text-right"> {canViewSubscriptions && (
<div className="flex items-center justify-end gap-1"> <TableHead className="text-right">
Подписчики <div className="flex items-center justify-end gap-1">
<Tooltip> Подписчики
<TooltipTrigger asChild> <Tooltip>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" /> <TooltipTrigger asChild>
</TooltipTrigger> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
<TooltipContent> </TooltipTrigger>
Привлеченные подписчики <TooltipContent>
</TooltipContent> Привлеченные подписчики
</Tooltip> </TooltipContent>
</div> </Tooltip>
</TableHead> </div>
</TableHead>
)}
<TableHead className="text-right"> <TableHead className="text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
Сред. CPS Сред. CPS
@@ -454,28 +487,44 @@ export default function CreativesPage() {
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<Badge variant="secondary"> <Badge variant="secondary">
{creative.analytics?.placements_count ?? creative.placements_count} {canViewAnalytics
? creative.analytics?.placements_count ?? creative.placements_count
: creative.placements_count}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right font-medium"> <TableCell className="text-right font-medium">
{loadingAnalytics ? ( {canViewAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" /> loadingAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" />
) : (
formatCurrency(creative.analytics?.total_cost)
)
) : ( ) : (
formatCurrency(creative.analytics?.total_cost) "—"
)} )}
</TableCell> </TableCell>
{canViewSubscriptions && (
<TableCell className="text-right">
{canViewAnalytics ? (
loadingAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" />
) : (
formatNumber(creative.analytics?.total_subscriptions)
)
) : (
"—"
)}
</TableCell>
)}
<TableCell className="text-right"> <TableCell className="text-right">
{loadingAnalytics ? ( {canViewAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" /> loadingAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" />
) : (
formatCurrency(creative.analytics?.avg_cpf)
)
) : ( ) : (
formatNumber(creative.analytics?.total_subscriptions) "—"
)}
</TableCell>
<TableCell className="text-right">
{loadingAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" />
) : (
formatCurrency(creative.analytics?.avg_cpf)
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -487,20 +536,20 @@ export default function CreativesPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{canWrite && creative.status === "active" && ( {canWriteCreatives && creative.status === "active" && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-8 w-8"
asChild asChild
>
<Link
href={`/dashboard/${workspaceId}/purchase-plans/${creative.project_id}?openPlacement=true&creative_id=${creative.id}`}
title="Создать размещение"
> >
<Link <Plus className="h-3.5 w-3.5" />
href={`/dashboard/${workspaceId}/purchase-plans/${creative.project_id}?openPlacement=true&creative_id=${creative.id}`} </Link>
title="Создать размещение" </Button>
>
<Plus className="h-3.5 w-3.5" />
</Link>
</Button>
)} )}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -510,32 +559,28 @@ export default function CreativesPage() {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link href={`/dashboard/${workspaceId}/creatives/${creative.id}`}>
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
>
<Eye className="h-4 w-4 mr-2" /> <Eye className="h-4 w-4 mr-2" />
Просмотр Просмотр
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> {canViewAnalytics && (
<Link <DropdownMenuItem asChild>
href={`/dashboard/${workspaceId}/analytics/placements?creative_id=${creative.id}`} <Link href={`/dashboard/${workspaceId}/analytics/placements?creative_id=${creative.id}`}>
> <BarChart3 className="h-4 w-4 mr-2" />
<BarChart3 className="h-4 w-4 mr-2" /> Аналитика
Аналитика </Link>
</Link> </DropdownMenuItem>
</DropdownMenuItem> )}
<DropdownMenuItem onClick={() => handleCopyText(creative.text)}> <DropdownMenuItem onClick={() => handleCopyText(creative.text)}>
<Copy className="h-4 w-4 mr-2" /> <Copy className="h-4 w-4 mr-2" />
Копировать текст Копировать текст
</DropdownMenuItem> </DropdownMenuItem>
{canWrite && ( {canWriteCreatives && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link href={`/dashboard/${workspaceId}/creatives/${creative.id}?edit=true`}>
href={`/dashboard/${workspaceId}/creatives/${creative.id}?edit=true`}
>
<Pencil className="h-4 w-4 mr-2" /> <Pencil className="h-4 w-4 mr-2" />
Редактировать Редактировать
</Link> </Link>
@@ -546,7 +591,7 @@ export default function CreativesPage() {
</DropdownMenuItem> </DropdownMenuItem>
{creative.placements_count === 0 && ( {creative.placements_count === 0 && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleDelete(creative.id)} onClick={() => handleDelete(creative.id, creative.name)}
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
@@ -594,7 +639,7 @@ export default function CreativesPage() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Stats */} {/* Stats */}
{creative.analytics && ( {canViewAnalytics && creative.analytics && (
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-muted-foreground" /> <ShoppingCart className="h-4 w-4 text-muted-foreground" />
@@ -605,15 +650,17 @@ export default function CreativesPage() {
<div className="text-xs text-muted-foreground">Потрачено</div> <div className="text-xs text-muted-foreground">Потрачено</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> {canViewSubscriptions && (
<Users className="h-4 w-4 text-muted-foreground" /> <div className="flex items-center gap-2">
<div> <Users className="h-4 w-4 text-muted-foreground" />
<div className="font-medium"> <div>
{formatNumber(creative.analytics.total_subscriptions)} <div className="font-medium">
{formatNumber(creative.analytics.total_subscriptions)}
</div>
<div className="text-xs text-muted-foreground">Подписчиков</div>
</div> </div>
<div className="text-xs text-muted-foreground">Подписчиков</div>
</div> </div>
</div> )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" /> <Eye className="h-4 w-4 text-muted-foreground" />
<div> <div>

View File

@@ -4,7 +4,7 @@
// Projects Page (Target Channels) - Enhanced with Statistics // Projects Page (Target Channels) - Enhanced with Statistics
// ============================================================================ // ============================================================================
import { useEffect, useRef, useState } from "react"; import { useEffect, useCallback, useRef, useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { import {
@@ -19,6 +19,7 @@ import {
Info, Info,
Bot, Bot,
Settings, Settings,
RefreshCw,
FolderKanban, FolderKanban,
Archive, Archive,
RotateCcw, RotateCcw,
@@ -60,6 +61,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Card, Card,
CardContent, CardContent,
@@ -86,6 +88,8 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; 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 { BOT_USERNAME } from "@/lib/utils/constants";
import type { Project, SpendingAnalytics } from "@/lib/types/api"; import type { Project, SpendingAnalytics } from "@/lib/types/api";
@@ -143,48 +147,62 @@ const formatStatus = (status: string): string => {
export default function ProjectsPage() { export default function ProjectsPage() {
const params = useParams(); const params = useParams();
const workspaceId = params?.workspaceId as string; const workspaceId = params?.workspaceId as string;
const { projects, isLoadingProjects, currentWorkspace, refreshProjects, workspaces, isAdmin, isWorkspaceAdmin } = useWorkspace(); const { currentWorkspace, workspaces, isAdmin, isWorkspaceAdmin, hasPermission, isDemoMode } = useWorkspace();
const [projectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([]); // 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 [loadingStats, setLoadingStats] = useState(false);
const [viewMode, setViewMode] = useState<"grid" | "table">("grid"); const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
const [settingsProject, setSettingsProject] = useState<Project | null>(null); const [settingsProject, setSettingsProject] = useState<Project | null>(null);
const [inviteLinkType, setInviteLinkType] = useState<"public" | "approval">("public"); 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 [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteConfirmationText, setDeleteConfirmationText] = useState(""); const [deleteConfirmationText, setDeleteConfirmationText] = useState("");
// Move project state // Move project state
const [showMoveDialog, setShowMoveDialog] = useState(false); const [showMoveDialog, setShowMoveDialog] = useState(false);
const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>(""); const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
const [isMoving, setIsMoving] = useState(false);
const [targetWorkspaceAdmin, setTargetWorkspaceAdmin] = useState(false); const [targetWorkspaceAdmin, setTargetWorkspaceAdmin] = useState(false);
const [isCheckingTargetPermissions, setIsCheckingTargetPermissions] = useState(false); const [isCheckingTargetPermissions, setIsCheckingTargetPermissions] = useState(false);
const { isDemoMode } = useWorkspace(); // Show archived projects toggle - must be defined before loadProjects
const [showArchived, setShowArchived] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
// Poll projects list every 5s so newly added projects (via bot) appear automatically. // Load projects with include_archived support
const refreshProjectsRef = useRef(refreshProjects); 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(() => { useEffect(() => {
refreshProjectsRef.current = refreshProjects; loadProjects();
}, [refreshProjects]); }, [loadProjects]);
useEffect(() => {
if (!workspaceId) return;
if (isDemoMode) return;
const intervalId = window.setInterval(() => {
if (typeof document !== "undefined" && document.hidden) return;
void refreshProjectsRef.current();
}, 5000);
return () => {
window.clearInterval(intervalId);
};
}, [workspaceId, isDemoMode]);
const handleOpenSettings = (project: Project) => { const handleOpenSettings = (project: Project) => {
setSettingsProject(project); setSettingsProject(project);
@@ -194,69 +212,97 @@ export default function ProjectsPage() {
const handleSaveSettings = async () => { const handleSaveSettings = async () => {
if (!settingsProject) return; if (!settingsProject) return;
try { await toast.promise(
setIsUpdatingSettings(true); (async () => {
const api = isDemoMode ? demoAwareProjectsApi : projectsApi; const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.updateInviteLinkType(workspaceId, settingsProject.id, { await api.updateInviteLinkType(workspaceId, settingsProject.id, {
purchase_invite_type_default: inviteLinkType, purchase_invite_type_default: inviteLinkType,
}); });
await refreshProjects(); await loadProjects();
setSettingsProject(null); })(),
} catch (err) { {
console.error("Failed to update settings:", err); loading: "Сохранение настроек...",
} finally { success: () => {
setIsUpdatingSettings(false); setSettingsProject(null);
} return "Настройки сохранены";
},
error: (err) => {
handleApiError(err);
return "Не удалось сохранить настройки";
},
}
);
}; };
const handleArchive = async () => { const handleArchive = async () => {
if (!settingsProject) return; if (!settingsProject) return;
try { await toast.promise(
setIsArchiving(true); (async () => {
const api = isDemoMode ? demoAwareProjectsApi : projectsApi; const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.archive(workspaceId, settingsProject.id); await api.archive(workspaceId, settingsProject.id);
await refreshProjects(); await loadProjects();
setSettingsProject(null); })(),
} catch (err) { {
console.error("Failed to archive project:", err); loading: "Архивирование проекта...",
} finally { success: () => {
setIsArchiving(false); setSettingsProject(null);
} return `Проект «${settingsProject.title}» архивирован`;
},
error: (err) => {
handleApiError(err);
return "Не удалось архивировать проект";
},
}
);
}; };
const handleUnarchive = async () => { const handleUnarchive = async () => {
if (!settingsProject) return; if (!settingsProject) return;
try { await toast.promise(
setIsUnarchiving(true); (async () => {
const api = isDemoMode ? demoAwareProjectsApi : projectsApi; const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.unarchive(workspaceId, settingsProject.id); await api.unarchive(workspaceId, settingsProject.id);
await refreshProjects(); await loadProjects();
setSettingsProject(null); })(),
} catch (err) { {
console.error("Failed to unarchive project:", err); loading: "Восстановление проекта...",
} finally { success: () => {
setIsUnarchiving(false); setSettingsProject(null);
} return `Проект «${settingsProject.title}» восстановлен`;
},
error: (err) => {
handleApiError(err);
return "Не удалось восстановить проект";
},
}
);
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!settingsProject) return; if (!settingsProject) return;
try { await toast.promise(
setIsDeleting(true); (async () => {
const api = isDemoMode ? demoAwareProjectsApi : projectsApi; const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.delete(workspaceId, settingsProject.id); await api.delete(workspaceId, settingsProject.id);
await refreshProjects(); await loadProjects();
setSettingsProject(null); })(),
setShowDeleteDialog(false); {
setDeleteConfirmationText(""); loading: "Удаление проекта...",
} catch (err) { success: () => {
console.error("Failed to delete project:", err); setSettingsProject(null);
} finally { setShowDeleteDialog(false);
setIsDeleting(false); setDeleteConfirmationText("");
} return `Проект «${settingsProject.title}» удалён`;
},
error: (err) => {
handleApiError(err);
return "Не удалось удалить проект";
},
}
);
}; };
const handleOpenDeleteDialog = () => { const handleOpenDeleteDialog = () => {
@@ -312,20 +358,29 @@ export default function ProjectsPage() {
const handleMoveProject = async () => { const handleMoveProject = async () => {
if (!settingsProject || !targetWorkspaceId || !targetWorkspaceAdmin) return; if (!settingsProject || !targetWorkspaceId || !targetWorkspaceAdmin) return;
try { const targetWorkspace = workspaces.find(w => w.id === targetWorkspaceId);
setIsMoving(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi; await toast.promise(
await api.move(workspaceId, settingsProject.id, { target_workspace_id: targetWorkspaceId }); (async () => {
await refreshProjects(); const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
setSettingsProject(null); await api.move(workspaceId, settingsProject.id, { target_workspace_id: targetWorkspaceId });
setShowMoveDialog(false); await loadProjects();
setTargetWorkspaceId(""); })(),
setTargetWorkspaceAdmin(false); {
} catch (err) { loading: "Перемещение проекта...",
console.error("Failed to move project:", err); success: () => {
} finally { setSettingsProject(null);
setIsMoving(false); setShowMoveDialog(false);
} setTargetWorkspaceId("");
setTargetWorkspaceAdmin(false);
return `Проект перемещён в «${targetWorkspace?.name}»`;
},
error: (err) => {
handleApiError(err);
return "Не удалось переместить проект";
},
}
);
}; };
const getChannelAvatar = (project: Project) => { const getChannelAvatar = (project: Project) => {
@@ -335,17 +390,30 @@ export default function ProjectsPage() {
return null; 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 // Load statistics for each project
useEffect(() => { useEffect(() => {
if (projects.length === 0) { if (localProjects.length === 0) {
setProjectsWithStats([]); setProjectsWithStats([]);
return; return;
} }
if (!canViewAnalytics) {
setProjectsWithStats(localProjects.map(p => ({ ...p, stats: undefined })));
setLoadingStats(false);
return;
}
const loadStats = async () => { const loadStats = async () => {
setLoadingStats(true); setLoadingStats(true);
try { try {
const statsPromises = projects.map(async (project) => { const statsPromises = localProjects.map(async (project) => {
try { try {
const spending = await demoAwareAnalyticsApi.spending(workspaceId, { const spending = await demoAwareAnalyticsApi.spending(workspaceId, {
project_id: project.id, project_id: project.id,
@@ -376,17 +444,23 @@ export default function ProjectsPage() {
setProjectsWithStats(results); setProjectsWithStats(results);
} catch (err) { } catch (err) {
console.error("Failed to load project stats:", err); console.error("Failed to load project stats:", err);
setProjectsWithStats(projects); setProjectsWithStats(localProjects);
} finally { } finally {
setLoadingStats(false); setLoadingStats(false);
} }
}; };
loadStats(); loadStats();
}, [projects, workspaceId]); }, [localProjects, workspaceId, canViewAnalytics]);
const isLoading = isLoadingProjects || loadingStats; 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 ( return (
<TooltipProvider> <TooltipProvider>
<DashboardHeader <DashboardHeader
@@ -467,11 +541,33 @@ export default function ProjectsPage() {
</AlertDescription> </AlertDescription>
</Alert> </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 ? ( {isLoading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div> </div>
) : projectsWithStats.length === 0 ? ( ) : filteredProjects.length === 0 ? (
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" /> <FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
@@ -533,7 +629,7 @@ export default function ProjectsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{projectsWithStats.map((project) => ( {filteredProjects.map((project) => (
<TableRow key={project.id}> <TableRow key={project.id}>
<TableCell> <TableCell>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -617,6 +713,7 @@ export default function ProjectsPage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleOpenSettings(project)} onClick={() => handleOpenSettings(project)}
disabled={!canEditProjects}
> >
<Settings className="h-3 w-3" /> <Settings className="h-3 w-3" />
</Button> </Button>
@@ -629,7 +726,7 @@ export default function ProjectsPage() {
</Card> </Card>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projectsWithStats.map((project) => ( {filteredProjects.map((project) => (
<Card key={project.id} className="overflow-hidden"> <Card key={project.id} className="overflow-hidden">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -747,6 +844,7 @@ export default function ProjectsPage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleOpenSettings(project)} onClick={() => handleOpenSettings(project)}
disabled={!canEditProjects}
> >
<Settings className="h-3.5 w-3.5" /> <Settings className="h-3.5 w-3.5" />
</Button> </Button>
@@ -792,47 +890,29 @@ export default function ProjectsPage() {
<Button <Button
variant="outline" variant="outline"
onClick={handleUnarchive} onClick={handleUnarchive}
disabled={isUnarchiving || isDeleting || isUpdatingSettings} disabled={!canEditProjects || isDemoMode}
className="w-full justify-start" className="w-full justify-start"
> >
{isUnarchiving ? ( <RotateCcw className="h-4 w-4 mr-2" />
<> Восстановить из архива
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Восстановление...
</>
) : (
<>
<RotateCcw className="h-4 w-4 mr-2" />
Восстановить из архива
</>
)}
</Button> </Button>
) : ( ) : (
<Button <Button
variant="outline" variant="outline"
onClick={handleArchive} onClick={handleArchive}
disabled={isArchiving || isDeleting || isUpdatingSettings} disabled={!canEditProjects || isDemoMode}
className="w-full justify-start" className="w-full justify-start"
> >
{isArchiving ? ( <Archive className="h-4 w-4 mr-2" />
<> Архивировать проект
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Архивирование...
</>
) : (
<>
<Archive className="h-4 w-4 mr-2" />
Архивировать проект
</>
)}
</Button> </Button>
)} )}
<Button <Button
variant="outline" variant="outline"
onClick={handleOpenMoveDialog} onClick={handleOpenMoveDialog}
disabled={!isAdmin || isArchiving || isDeleting || isUpdatingSettings || isUnarchiving} disabled={!isAdmin || !canEditProjects || isDemoMode}
className="w-full justify-start" className="w-full justify-start"
title={!isAdmin ? "Вы должны быть администратором для перемещения проекта" : undefined} title={!isAdmin ? "Вы должны быть администратором для перемещения проекта" : !canEditProjects ? "Нет прав на редактирование проектов" : undefined}
> >
<ArrowRightFromLine className="h-4 w-4 mr-2" /> <ArrowRightFromLine className="h-4 w-4 mr-2" />
Переместить в другой воркспейс Переместить в другой воркспейс
@@ -840,7 +920,7 @@ export default function ProjectsPage() {
<Button <Button
variant="destructive" variant="destructive"
onClick={handleOpenDeleteDialog} onClick={handleOpenDeleteDialog}
disabled={isArchiving || isDeleting || isUpdatingSettings || settingsProject?.status === "archived"} disabled={!canEditProjects || isDemoMode || settingsProject?.status === "archived"}
className="w-full justify-start" className="w-full justify-start"
> >
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
@@ -858,16 +938,9 @@ export default function ProjectsPage() {
</Button> </Button>
<Button <Button
onClick={handleSaveSettings} onClick={handleSaveSettings}
disabled={isUpdatingSettings || isArchiving || isDeleting || isUnarchiving} disabled={!canEditProjects || isDemoMode}
> >
{isUpdatingSettings ? ( Сохранить
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Сохранение...
</>
) : (
"Сохранить"
)}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -897,17 +970,10 @@ export default function ProjectsPage() {
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleDelete} onClick={handleDelete}
disabled={isDeleting || deleteConfirmationText !== `Удалить ${settingsProject?.title}`} disabled={deleteConfirmationText !== `Удалить ${settingsProject?.title}` || isDemoMode}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
{isDeleting ? ( Удалить
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Удаление...
</>
) : (
"Удалить"
)}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -978,19 +1044,10 @@ export default function ProjectsPage() {
</Button> </Button>
<Button <Button
onClick={handleMoveProject} onClick={handleMoveProject}
disabled={!targetWorkspaceId || !targetWorkspaceAdmin || isMoving || isCheckingTargetPermissions} disabled={!targetWorkspaceId || !targetWorkspaceAdmin || isDemoMode}
> >
{isMoving ? ( <ArrowRightFromLine className="h-4 w-4 mr-2" />
<> Переместить
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Перемещение...
</>
) : (
<>
<ArrowRightFromLine className="h-4 w-4 mr-2" />
Переместить
</>
)}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -83,6 +83,7 @@ import { PlacementStatusSelector } from "@/components/placement-status-selector"
import { PlacementPostStatusSelector } from "@/components/placement-post-status-selector"; import { PlacementPostStatusSelector } from "@/components/placement-post-status-selector";
import { PlacementWizard } from "@/components/placement-wizard"; import { PlacementWizard } from "@/components/placement-wizard";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner";
import type { import type {
Project, Project,
Placement, Placement,
@@ -742,9 +743,12 @@ function CellRenderer({
); );
case "subscriptions": case "subscriptions":
const subsCountValue = placement.placement_post?.subscriptions_count;
// Show "—" if subscriptions are hidden due to permissions (0 means hidden)
const showSubs = subsCountValue === 0 ? "—" : formatNumber(subsCountValue);
return ( return (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs"> <td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{formatNumber(placement.placement_post?.subscriptions_count)} {showSubs}
</td> </td>
); );
@@ -1076,7 +1080,7 @@ export default function PurchasePlanDetailPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const workspaceId = params?.workspaceId as string; const workspaceId = params?.workspaceId as string;
const projectId = params?.projectId as string; const projectId = params?.projectId as string;
const { projects, hasPermission } = useWorkspace(); const { projects, hasPermission, canViewAnalytics, canViewSubscriptions } = useWorkspace();
const [placements, setPlacements] = useState<Placement[]>([]); const [placements, setPlacements] = useState<Placement[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -1098,9 +1102,30 @@ export default function PurchasePlanDetailPage() {
const [showCopyDialog, setShowCopyDialog] = useState(false); const [showCopyDialog, setShowCopyDialog] = useState(false);
const [targetProjectId, setTargetProjectId] = useState<string | null>(null); const [targetProjectId, setTargetProjectId] = useState<string | null>(null);
const [copyingChannels, setCopyingChannels] = useState(false); const [copyingChannels, setCopyingChannels] = useState(false);
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(
new Set(DEFAULT_VISIBLE_COLUMNS) // Initialize visible columns based on permissions
); const initialVisibleColumns = useMemo(() => {
const columns = new Set(DEFAULT_VISIBLE_COLUMNS);
// Hide subscription-related columns if user doesn't have analytics_read permission
if (!canViewSubscriptions) {
columns.delete("subscriptions");
columns.delete("cpf");
columns.delete("conversion_24h");
columns.delete("conversion_48h");
columns.delete("conversion_total");
columns.delete("total_unsubs");
columns.delete("unsub_percent");
columns.delete("total_active");
}
// Hide views and cpm if user has no analytics permissions at all
if (!canViewAnalytics) {
columns.delete("views");
columns.delete("cpm");
}
return columns;
}, [canViewSubscriptions, canViewAnalytics]);
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(initialVisibleColumns);
const [showColumnsDialog, setShowColumnsDialog] = useState(false); const [showColumnsDialog, setShowColumnsDialog] = useState(false);
// Creative selection dialog // Creative selection dialog
@@ -1228,14 +1253,15 @@ export default function PurchasePlanDetailPage() {
const handleOpenCreativeSend = async (placement: PlacementWithStats) => { const handleOpenCreativeSend = async (placement: PlacementWithStats) => {
if (!placement.creative_id || isDemoMode) return; if (!placement.creative_id || isDemoMode) return;
try { await toast.promise(
await placementsApi.sendCreative(workspaceId, projectId, placement.id); placementsApi.sendCreative(workspaceId, projectId, placement.id),
alert("Креатив успешно отправлен"); {
} catch (err) { loading: "Отправка креатива...",
console.error("Failed to send creative:", err); success: "Креатив успешно отправлен",
alert("Не удалось отправить креатив"); error: "Не удалось отправить креатив",
} }
);
}; };
const handleSelectCreative = (creative: { id: string; name: string }) => { const handleSelectCreative = (creative: { id: string; name: string }) => {

View File

@@ -69,7 +69,7 @@ export default function PurchasePlansPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const workspaceId = params?.workspaceId as string; const workspaceId = params?.workspaceId as string;
const { projects, isLoadingProjects, currentWorkspace } = useWorkspace(); const { projects, isLoadingProjects, currentWorkspace, canViewAnalytics, canViewSubscriptions } = useWorkspace();
const [placementsByProject, setPlacementsByProject] = useState<Record<string, Placement[]>>({}); const [placementsByProject, setPlacementsByProject] = useState<Record<string, Placement[]>>({});
const [loadingStats, setLoadingStats] = useState(true); const [loadingStats, setLoadingStats] = useState(true);
@@ -222,13 +222,17 @@ export default function PurchasePlansPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" /> <Eye className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Охват:</span> <span className="text-sm text-muted-foreground">Охват:</span>
<span className="font-medium">{formatCompact(summary.total_views)}</span> <span className="font-medium">
{canViewAnalytics ? formatCompact(summary.total_views) : "—"}
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" /> <Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Подписок:</span> <span className="text-sm text-muted-foreground">Подписок:</span>
<span className="font-medium">{formatCompact(summary.total_subscriptions)}</span> <span className="font-medium">
{canViewSubscriptions ? formatCompact(summary.total_subscriptions) : "—"}
</span>
</div> </div>
<div className="pt-2 border-t space-y-2"> <div className="pt-2 border-t space-y-2">
@@ -244,7 +248,9 @@ export default function PurchasePlansPage() {
<TrendingUp className="h-4 w-4 text-muted-foreground" /> <TrendingUp className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">CPF</span> <span className="text-sm text-muted-foreground">CPF</span>
</div> </div>
<span className="font-medium">{formatCurrency(summary.avg_cpf)}</span> <span className="font-medium">
{canViewSubscriptions ? formatCurrency(summary.avg_cpf) : "—"}
</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -37,6 +37,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { toast } from "sonner";
import type { WorkspaceInvite } from "@/lib/types/api"; import type { WorkspaceInvite } from "@/lib/types/api";
// ============================================================================ // ============================================================================
@@ -51,8 +52,6 @@ export default function InvitesPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
loadInvites(); loadInvites();
@@ -74,27 +73,28 @@ export default function InvitesPage() {
if (!username.trim()) return; if (!username.trim()) return;
const cleanUsername = username.trim().replace(/^@/, ""); const cleanUsername = username.trim().replace(/^@/, "");
try { await toast.promise(
setIsCreating(true); (async () => {
setError(null); const invite = await invitesApi.create(workspaceId, {
username: cleanUsername,
// Remove @ if present });
setInvites((prev) => [invite, ...prev]);
const invite = await invitesApi.create(workspaceId, { setShowDialog(false);
username: cleanUsername, setUsername("");
}); return invite;
})(),
setInvites((prev) => [invite, ...prev]); {
setShowDialog(false); loading: "Отправка приглашения...",
setUsername(""); success: `Приглашение отправлено @${cleanUsername}`,
} catch (err: any) { error: (err) => {
const errorMessage = err?.error?.message || err?.detail; const errorMessage = err?.error?.message || err?.detail;
if (errorMessage?.includes("not found")) { if (errorMessage?.includes("not found")) {
setError(`Пользователь @${cleanUsername} не зарегистрирован на платформе. Для получения приглашения необходимо зарегистрироваться.`); return `Пользователь @${cleanUsername} не зарегистрирован на платформе`;
} else { }
setError(errorMessage || "Не удалось отправить приглашение"); return errorMessage || "Не удалось отправить приглашение";
},
} }
} );
}; };
const getStatusIcon = (status: WorkspaceInvite["status"]) => { const getStatusIcon = (status: WorkspaceInvite["status"]) => {
@@ -161,27 +161,22 @@ export default function InvitesPage() {
приглашения через бота приглашения через бота
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="username">Telegram username</Label> <Label htmlFor="username">Telegram username</Label>
<Input <Input
id="username" id="username"
placeholder="@username" placeholder="@username"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
handleCreateInvite(); handleCreateInvite();
} }
}} }}
/> />
</div> </div>
{error && ( </div>
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
</div>
<DialogFooter> <DialogFooter>
<Button <Button
variant="outline" variant="outline"
@@ -191,13 +186,9 @@ export default function InvitesPage() {
</Button> </Button>
<Button <Button
onClick={handleCreateInvite} onClick={handleCreateInvite}
disabled={!username.trim() || isCreating} disabled={!username.trim()}
> >
{isCreating ? ( <Mail className="h-4 w-4 mr-2" />
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Mail className="h-4 w-4 mr-2" />
)}
Отправить Отправить
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -4,12 +4,15 @@
// Workspace Settings Page // Workspace Settings Page
// ============================================================================ // ============================================================================
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, useMemo } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { Loader2, Settings, Pencil, Trash2, Users, Shield, ChevronDown, UserPlus, Mail, Clock, Upload, ImageIcon } from "lucide-react"; import { Loader2, Settings, Pencil, Trash2, Users, Shield, ChevronDown, UserPlus, Mail, Clock, Upload, ImageIcon } from "lucide-react";
import { toast } from "sonner";
import { DashboardHeader } from "@/components/layout/dashboard-header"; import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider"; import { useWorkspace } from "@/components/providers/workspace-provider";
import { useAuth } from "@/components/auth/auth-provider";
import { workspacesApi, membersApi, invitesApi } from "@/lib/api"; import { workspacesApi, membersApi, invitesApi } from "@/lib/api";
import type { User } from "@/lib/types/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -51,7 +54,13 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { PERMISSION_LABELS } from "@/lib/types/api"; import { PERMISSION_LABELS } from "@/lib/types/api";
import type { WorkspaceMember, WorkspaceInvite, Project, PermissionKey, PermissionScope } from "@/lib/types/api";
import { PermissionModal } from "@/components/ui/permission-modal"; import { PermissionModal } from "@/components/ui/permission-modal";
import {
handleApiError,
showPermissionErrorToast,
showSuccessToast,
} from "@/lib/utils/error-handler";
// ============================================================================ // ============================================================================
// Component // Component
@@ -62,6 +71,8 @@ export default function SettingsPage() {
const router = useRouter(); const router = useRouter();
const workspaceId = params?.workspaceId as string; const workspaceId = params?.workspaceId as string;
const { currentWorkspace, refreshWorkspaces } = useWorkspace(); const { currentWorkspace, refreshWorkspaces } = useWorkspace();
const { user } = useAuth();
const currentUser = user;
const [name, setName] = useState(currentWorkspace?.name || ""); const [name, setName] = useState(currentWorkspace?.name || "");
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
@@ -91,6 +102,14 @@ export default function SettingsPage() {
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [loadingProjects, setLoadingProjects] = useState(false); const [loadingProjects, setLoadingProjects] = useState(false);
const isOnlyAdmin = useMemo(() => {
if (!currentUser || !members.length) return false;
const adminMembers = members.filter((m) =>
m.permissions.some((p) => p.key === "admin_full")
);
return adminMembers.length === 1 && adminMembers[0]?.user.id === currentUser.id;
}, [currentUser, members]);
useEffect(() => { useEffect(() => {
if (currentWorkspace) { if (currentWorkspace) {
setName(currentWorkspace.name); setName(currentWorkspace.name);
@@ -139,59 +158,142 @@ export default function SettingsPage() {
wsId: string, wsId: string,
memberId: string, memberId: string,
userId: string, userId: string,
permissions: { key: PermissionKey; scopes?: { type: string; id: string }[] }[] permissions: { key: PermissionKey; scopes?: PermissionScope[] }[]
) => { ) => {
await membersApi.updatePermissions(wsId, userId, { permissions }); await toast.promise(
await loadMembers(); membersApi.updatePermissions(wsId, memberId, { permissions }),
{
loading: "Сохранение прав доступа...",
success: () => {
loadMembers();
return "Права доступа обновлены";
},
error: (err) => {
handleApiError(err);
return "Не удалось сохранить права";
},
}
);
setShowPermissionModal(false);
}; };
const handleUpdate = async () => { const handleUpdate = async () => {
if (!name.trim() || name === currentWorkspace?.name) return; if (!name.trim() || name === currentWorkspace?.name) return;
try { await toast.promise(
setIsUpdating(true); workspacesApi.update(workspaceId, { name: name.trim() }),
await workspacesApi.update(workspaceId, { name: name.trim() }); {
await refreshWorkspaces(); loading: "Сохранение названия...",
} catch (err) { success: () => {
console.error("Failed to update workspace:", err); refreshWorkspaces();
} finally { return "Название обновлено";
setIsUpdating(false); },
} error: (err) => {
handleApiError(err);
return "Не удалось сохранить название";
},
}
);
setIsUpdating(false);
}; };
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
try { await toast.promise(
setIsUploadingAvatar(true); workspacesApi.uploadAvatar(workspaceId, file),
const updatedWorkspace = await workspacesApi.uploadAvatar(workspaceId, file); {
await refreshWorkspaces(); loading: "Загрузка аватара...",
if (fileInputRef.current) { success: () => {
fileInputRef.current.value = ""; refreshWorkspaces();
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setAvatarFile(null);
return "Аватар загружен";
},
error: (err) => {
handleApiError(err);
return "Не удалось загрузить аватар";
},
} }
setAvatarFile(null); );
} catch (err) { setIsUploadingAvatar(false);
console.error("Failed to upload avatar:", err); };
} finally {
setIsUploadingAvatar(false); const handleAvatarDelete = async () => {
} await toast.promise(
workspacesApi.deleteAvatar(workspaceId),
{
loading: "Удаление аватара...",
success: () => {
refreshWorkspaces();
return "Аватар удалён";
},
error: (err) => {
handleApiError(err);
return "Не удалось удалить аватар";
},
}
);
setIsDeletingAvatar(false);
};
const handleCreateInvite = async () => {
if (!username.trim()) return;
const cleanUsername = username.trim().replace(/^@/, "");
await toast.promise(
invitesApi.create(workspaceId, {
username: cleanUsername,
}),
{
loading: "Отправка приглашения...",
success: (invite) => {
setInvites((prev) => [invite, ...prev]);
setShowInviteDialog(false);
setUsername("");
return `Приглашение отправлено @${cleanUsername}`;
},
error: (err: any) => {
const errorMessage = err?.error?.message || err?.detail;
if (errorMessage?.includes("not found")) {
setError(`Пользователь @${cleanUsername} не зарегистрирован на платформе.`);
return "Пользователь не найден";
}
if (errorMessage?.includes("already exists") || errorMessage?.includes("уже")) {
return "Приглашение уже существует или пользователь уже участник";
}
handleApiError(err);
return "Не удалось отправить приглашение";
},
}
);
setIsCreating(false);
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (deleteConfirm !== `Удалить ${currentWorkspace?.name}`) return; if (deleteConfirm !== `Удалить ${currentWorkspace?.name}`) return;
try { await toast.promise(
setIsDeleting(true); workspacesApi.delete(workspaceId),
await workspacesApi.delete(workspaceId); {
router.replace("/"); loading: "Удаление воркспейса...",
} catch (err) { success: () => {
console.error("Failed to delete workspace:", err); router.replace("/");
} finally { return "Воркспейс удалён";
setIsDeleting(false); },
setShowDeleteDialog(false); error: (err) => {
setDeleteConfirm(""); handleApiError(err);
} return "Не удалось удалить воркспейс";
},
}
);
setIsDeleting(false);
setShowDeleteDialog(false);
setDeleteConfirm("");
}; };
const hasPermission = (member: WorkspaceMember, key: PermissionKey) => { const hasPermission = (member: WorkspaceMember, key: PermissionKey) => {
@@ -522,20 +624,20 @@ const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
)} )}
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge <Badge
variant={ variant={
member.status === "active" ? "default" : "secondary" member.status === "active" ? "default" : "secondary"
} }
> >
{member.status === "active" ? "Активен" : "Приглашён"} {member.status === "active" ? "Активен" : "Приглашён"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
{renderPermissionBadges(member)} {renderPermissionBadges(member)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -637,6 +739,8 @@ const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
projects={projects} projects={projects}
onSave={handlePermissionSave} onSave={handlePermissionSave}
workspaceId={workspaceId} workspaceId={workspaceId}
currentUserId={currentUser?.id || ""}
isOnlyAdmin={isOnlyAdmin}
/> />
)} )}
</div> </div>

View File

@@ -7,6 +7,7 @@
import React from "react"; import React from "react";
import { AuthProvider } from "@/components/auth/auth-provider"; import { AuthProvider } from "@/components/auth/auth-provider";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
interface ProvidersProps { interface ProvidersProps {
children: React.ReactNode; children: React.ReactNode;
@@ -20,7 +21,10 @@ export const Providers: React.FC<ProvidersProps> = ({ children }) => {
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<AuthProvider>{children}</AuthProvider> <AuthProvider>
{children}
<Toaster position="top-center" richColors />
</AuthProvider>
</ThemeProvider> </ThemeProvider>
); );
}; };

View File

@@ -15,6 +15,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useWorkspace } from "@/components/providers/workspace-provider"; import { useWorkspace } from "@/components/providers/workspace-provider";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -30,7 +31,7 @@ export const ProjectSelector: React.FC = () => {
if (isLoadingProjects) { if (isLoadingProjects) {
return ( return (
<div className="w-[240px] h-11 rounded-lg border border-input bg-background animate-pulse" /> <div className="w-[240px] h-10 rounded-lg border border-input bg-background animate-pulse" />
); );
} }
@@ -44,45 +45,56 @@ export const ProjectSelector: React.FC = () => {
const allSelected = selectedProjects.length === projects.length; const allSelected = selectedProjects.length === projects.length;
const getAvatarUrl = (project: typeof projects[0]) => {
return project.username
? `https://t.me/i/userpic/320/${project.username}.jpg`
: null;
};
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="outline" variant="outline"
className="w-[280px] h-auto min-h-[44px] py-2 px-3 rounded-lg justify-between" className="w-[240px] h-10 px-3 rounded-lg justify-start gap-2"
> >
<div className="flex flex-col items-start text-left overflow-hidden"> {selectedProjects.length === 0 ? (
{selectedProjects.length === 0 ? ( <>
<span className="text-muted-foreground">Выберите проекты</span> <span className="text-muted-foreground">Все проекты</span>
) : selectedProjects.length === 1 ? ( </>
<> ) : selectedProjects.length === 1 ? (
<span className="font-semibold text-base truncate max-w-[220px]"> <>
{selectedProjects[0].title} <Avatar className="h-6 w-6 shrink-0">
</span> <AvatarImage src={getAvatarUrl(selectedProjects[0]) || undefined} />
{selectedProjects[0].username && ( <AvatarFallback className="text-xs">
<span className="text-xs text-muted-foreground -mt-0.5"> {selectedProjects[0].title.charAt(0).toUpperCase()}
@{selectedProjects[0].username} </AvatarFallback>
</span> </Avatar>
)} <span className="truncate max-w-[180px]">
</> {selectedProjects[0].title}
) : allSelected ? ( </span>
<span className="font-semibold text-base">Все проекты</span> </>
) : ( ) : allSelected ? (
<> <>
<span className="font-semibold text-base"> <span className="font-medium">Все проекты</span>
{selectedProjects.length} проекта </>
</span> ) : (
<span className="text-xs text-muted-foreground -mt-0.5 truncate max-w-[220px]"> <>
{selectedProjects.map((p) => p.title).join(", ")} <Avatar className="h-6 w-6 shrink-0">
</span> <AvatarFallback className="text-xs">
</> {selectedProjects.length}
)} </AvatarFallback>
</div> </Avatar>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" /> <span className="truncate max-w-[180px]">
{selectedProjects.length} проекта
</span>
</>
)}
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 ml-auto" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-[280px] p-2" align="start"> <DropdownMenuContent className="w-[240px] p-2" align="start">
{/* Quick actions */} {/* Quick actions */}
<div className="flex gap-2 mb-2"> <div className="flex gap-2 mb-2">
<Button <Button
@@ -117,7 +129,7 @@ export const ProjectSelector: React.FC = () => {
<div <div
key={project.id} key={project.id}
className={cn( className={cn(
"flex items-center gap-3 px-2 py-2 rounded-md cursor-pointer hover:bg-accent", "flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer hover:bg-accent",
isSelected && "bg-accent/50" isSelected && "bg-accent/50"
)} )}
onClick={() => !isOnlyOne && toggleProject(project)} onClick={() => !isOnlyOne && toggleProject(project)}
@@ -128,15 +140,13 @@ export const ProjectSelector: React.FC = () => {
onCheckedChange={() => !isOnlyOne && toggleProject(project)} onCheckedChange={() => !isOnlyOne && toggleProject(project)}
className="shrink-0" className="shrink-0"
/> />
<Tv className="h-4 w-4 text-muted-foreground shrink-0" /> <Avatar className="h-5 w-5 shrink-0">
<div className="flex flex-col min-w-0 flex-1"> <AvatarImage src={getAvatarUrl(project) || undefined} />
<span className="font-medium truncate">{project.title}</span> <AvatarFallback className="text-[10px]">
{project.username && ( {project.title.charAt(0).toUpperCase()}
<span className="text-xs text-muted-foreground"> </AvatarFallback>
@{project.username} </Avatar>
</span> <span className="truncate text-sm">{project.title}</span>
)}
</div>
</div> </div>
); );
})} })}

View File

@@ -10,6 +10,7 @@ import React, {
useState, useState,
useEffect, useEffect,
useCallback, useCallback,
useMemo,
} from "react"; } from "react";
import { useParams, useRouter, usePathname } from "next/navigation"; import { useParams, useRouter, usePathname } from "next/navigation";
import { workspacesApi, projectsApi, membersApi, authApi } from "@/lib/api"; import { workspacesApi, projectsApi, membersApi, authApi } from "@/lib/api";
@@ -57,6 +58,10 @@ interface WorkspaceContextType {
isAdmin: boolean; isAdmin: boolean;
// Check if user is admin in a specific workspace // Check if user is admin in a specific workspace
isWorkspaceAdmin: (workspaceId: string) => Promise<boolean>; isWorkspaceAdmin: (workspaceId: string) => Promise<boolean>;
// Analytics permission helpers (memoized values, not functions)
hasAnyAnalyticsPermission: boolean;
canViewSubscriptions: boolean;
canViewAnalytics: boolean;
// Loading states // Loading states
isLoading: boolean; isLoading: boolean;
@@ -182,6 +187,32 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
[permissions, isAdmin] [permissions, isAdmin]
); );
// ============================================================================
// Analytics Permission Helpers
// ============================================================================
// Check if user has any analytics permission
const hasAnyAnalyticsPermission = useMemo((): boolean => {
if (isAdmin) return true;
return (
hasPermission("analytics_read") ||
hasPermission("analytics_without_clicks") ||
hasPermission("analytics_own_creatives")
);
}, [hasPermission, isAdmin]);
// Check if user can view subscriptions (false if only analytics_without_clicks)
const canViewSubscriptions = useMemo((): boolean => {
if (isAdmin) return true;
return hasPermission("analytics_read");
}, [hasPermission, isAdmin]);
// Check if user can view analytics (any analytics permission)
const canViewAnalytics = useMemo((): boolean => {
if (isAdmin) return true;
return hasAnyAnalyticsPermission;
}, [hasAnyAnalyticsPermission, isAdmin]);
// ============================================================================ // ============================================================================
// Load Workspaces // Load Workspaces
// ============================================================================ // ============================================================================
@@ -519,6 +550,9 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
hasPermission, hasPermission,
isAdmin, isAdmin,
isWorkspaceAdmin, isWorkspaceAdmin,
hasAnyAnalyticsPermission,
canViewSubscriptions,
canViewAnalytics,
// Loading states // Loading states
isLoading, isLoading,

View File

@@ -60,7 +60,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200",
className className
)} )}
{...props} {...props}

View File

@@ -1,23 +1,28 @@
"use client"; "use client";
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { Loader2, Shield, Search, X, Plus, Check, Info } from "lucide-react";
import type { WorkspaceMember, Project, PermissionKey } from "@/lib/types/api";
import { PERMISSION_LABELS, PERMISSION_GROUPS, PERMISSION_ICONS } from "@/lib/types/permission";
import { import {
hasGlobalPermission, Loader2,
hasScopedPermission, Shield,
getScopesForPermission, Search,
setScopedPermission, X,
toggleScopedPermission, Check,
removePermission, Info,
calculatePermissionDiff, Crown,
} from "@/lib/types/permission"; FolderOpen,
import { Button } from "@/components/ui/button"; Palette,
import { Input } from "@/components/ui/input"; ClipboardList,
import { Badge } from "@/components/ui/badge"; BarChart3,
import { Checkbox } from "@/components/ui/checkbox"; Users,
import { ScrollArea } from "@/components/ui/scroll-area"; Lock,
Eye,
Pencil,
ChartArea,
UserRoundX,
FileUser,
Globe,
} from "lucide-react";
import type { WorkspaceMember, Project, PermissionKey, PermissionScope } from "@/lib/types/api";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -31,7 +36,12 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface PermissionModalProps { interface PermissionModalProps {
@@ -39,8 +49,10 @@ interface PermissionModalProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
member: WorkspaceMember; member: WorkspaceMember;
projects: Project[]; projects: Project[];
onSave: (workspaceId: string, memberId: string, userId: string, permissions: { key: PermissionKey; scopes?: { type: string; id: string }[] }[]) => Promise<void>; onSave: (workspaceId: string, memberId: string, userId: string, permissions: { key: PermissionKey; scopes?: PermissionScope[] }[]) => Promise<void>;
workspaceId: string; workspaceId: string;
currentUserId: string;
isOnlyAdmin: boolean;
} }
interface PermissionState { interface PermissionState {
@@ -49,6 +61,27 @@ interface PermissionState {
projectIds: string[]; projectIds: string[];
} }
const PERMISSION_CONFIG: Record<PermissionKey, { icon: typeof Crown; label: string; group: string }> = {
admin_full: { icon: Crown, label: "Полный доступ", group: "admin" },
projects_read: { icon: Eye, label: "Просмотр проектов", group: "projects" },
projects_write: { icon: Pencil, label: "Управление проектами", group: "projects" },
creatives_read: { icon: Eye, label: "Просмотр креативов", group: "creatives" },
creatives_write: { icon: Pencil, label: "Создание и редактирование креативов", group: "creatives" },
placements_read: { icon: Eye, label: "Просмотр закупок", group: "placements" },
placements_write: { icon: Pencil, label: "Создание и редактирование закупок", group: "placements" },
analytics_read: { icon: ChartArea, label: "Полный доступ к статистике", group: "analytics" },
analytics_without_clicks: { icon: UserRoundX, label: "Статистика без переходов", group: "analytics" },
analytics_own_creatives: { icon: FileUser, label: "Статистика только своих креативов", group: "analytics" },
};
const GROUPS = [
{ key: "admin", label: "Администрирование", icon: Shield },
{ key: "projects", label: "Проекты", icon: FolderOpen },
{ key: "creatives", label: "Креативы", icon: Palette },
{ key: "placements", label: "Закупки", icon: ClipboardList },
{ key: "analytics", label: "Аналитика", icon: BarChart3 },
] as const;
const PRESETS = [ const PRESETS = [
{ label: "Полный доступ", keys: ["admin_full"] }, { label: "Полный доступ", keys: ["admin_full"] },
{ label: "Только чтение", keys: ["projects_read", "creatives_read", "placements_read", "analytics_read"] }, { label: "Только чтение", keys: ["projects_read", "creatives_read", "placements_read", "analytics_read"] },
@@ -63,6 +96,8 @@ export function PermissionModal({
projects, projects,
onSave, onSave,
workspaceId, workspaceId,
currentUserId,
isOnlyAdmin,
}: PermissionModalProps) { }: PermissionModalProps) {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [permissions, setPermissions] = useState<PermissionState[]>([]); const [permissions, setPermissions] = useState<PermissionState[]>([]);
@@ -89,7 +124,18 @@ export function PermissionModal({
} }
}, [open, member]); }, [open, member]);
const hasGlobalAdmin = useMemo(() => {
const admin = permissions.find((p) => p.key === "admin_full");
return admin?.isGlobal ?? false;
}, [permissions]);
const isPermissionDisabled = (key: PermissionKey): boolean => {
return hasGlobalAdmin && key !== "admin_full";
};
const handleToggleGlobal = (key: PermissionKey) => { const handleToggleGlobal = (key: PermissionKey) => {
if (hasGlobalAdmin && key !== "admin_full") return;
setPermissions((prev) => { setPermissions((prev) => {
const existing = prev.find((p) => p.key === key); const existing = prev.find((p) => p.key === key);
if (existing) { if (existing) {
@@ -102,6 +148,8 @@ export function PermissionModal({
}; };
const handleToggleProject = (key: PermissionKey, projectId: string) => { const handleToggleProject = (key: PermissionKey, projectId: string) => {
if (hasGlobalAdmin && key !== "admin_full") return;
setPermissions((prev) => { setPermissions((prev) => {
const existing = prev.find((p) => p.key === key); const existing = prev.find((p) => p.key === key);
if (existing) { if (existing) {
@@ -118,40 +166,6 @@ export function PermissionModal({
}); });
}; };
const handleAddProject = (key: PermissionKey, projectId: string) => {
setPermissions((prev) => {
const existing = prev.find((p) => p.key === key);
if (existing) {
return prev.map((p) =>
p.key === key
? { ...p, isGlobal: false, projectIds: [...new Set([...p.projectIds, projectId])] }
: p
);
}
return [
...prev,
{ key, isGlobal: false, projectIds: [projectId] },
];
});
};
const handleRemoveProject = (key: PermissionKey, projectId: string) => {
setPermissions((prev) => {
const existing = prev.find((p) => p.key === key);
if (!existing) return prev;
const newProjectIds = existing.projectIds.filter((id) => id !== projectId);
if (newProjectIds.length === 0) {
return prev.filter((p) => p.key !== key);
}
return prev.map((p) =>
p.key === key ? { ...p, projectIds: newProjectIds } : p
);
});
};
const toggleInArray = (arr: string[], item: string): string[] => { const toggleInArray = (arr: string[], item: string): string[] => {
if (arr.includes(item)) { if (arr.includes(item)) {
return arr.filter((i) => i !== item); return arr.filter((i) => i !== item);
@@ -255,35 +269,68 @@ export function PermissionModal({
setPermissions(perms); setPermissions(perms);
}; };
const renderPermissionRow = (key: PermissionKey, group: string) => { const isEditingOwnPermissions = member.user.id === currentUserId;
const renderPermissionRow = (key: PermissionKey) => {
const config = PERMISSION_CONFIG[key];
const current = permissions.find((p) => p.key === key); const current = permissions.find((p) => p.key === key);
const currentProjectIds = current?.projectIds || []; const currentProjectIds = current?.projectIds || [];
const isGlobal = current?.isGlobal ?? false; const isGlobal = current?.isGlobal ?? false;
const isDisabled = isPermissionDisabled(key);
return ( return (
<div key={key} className="space-y-2"> <div
<div className="flex items-center justify-between gap-4"> key={key}
<div className="flex items-center gap-2 min-w-0"> className={cn(
<span className="text-lg">{PERMISSION_ICONS[key]}</span> "space-y-2 transition-opacity",
<span className="font-medium truncate">{PERMISSION_LABELS[key]}</span> isDisabled && "opacity-50"
<Badge variant="outline" className="text-xs shrink-0"> )}
{group} >
</Badge> <div className="flex items-center justify-between gap-3 min-w-0">
<div className="flex items-center gap-2 min-w-0 flex-1">
{isDisabled ? (
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<config.icon className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className={cn("font-medium text-sm truncate", isDisabled && "text-muted-foreground")}>
{config.label}
</span>
{isDisabled && (
<Badge variant="secondary" className="text-xs shrink-0">
Выдан полный доступ
</Badge>
)}
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<Button <Button
variant={isGlobal ? "default" : "outline"} variant={isGlobal ? "default" : "outline"}
size="sm" size="sm"
onClick={() => handleToggleGlobal(key)} onClick={() => handleToggleGlobal(key)}
className="text-xs" disabled={isDisabled}
className={cn(
"text-xs shrink-0",
isDisabled && "cursor-not-allowed opacity-50"
)}
> >
<Globe className="h-3 w-3 mr-1" />
Глобально Глобально
</Button> </Button>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant={!isGlobal ? "default" : "outline"} size="sm" className="text-xs"> <Button
variant={!isGlobal ? "default" : "outline"}
size="sm"
disabled={isDisabled}
className={cn(
"text-xs shrink-0 min-w-[100px]",
isDisabled && "cursor-not-allowed opacity-50"
)}
>
<Search className="h-3 w-3 mr-1" /> <Search className="h-3 w-3 mr-1" />
Проекты ({currentProjectIds.length}) {currentProjectIds.length > 0
? `${currentProjectIds.length} проект${currentProjectIds.length === 1 ? "" : currentProjectIds.length > 4 ? "ов" : "а"}`
: "Проекты"}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-80" align="end"> <PopoverContent className="w-80" align="end">
@@ -304,17 +351,23 @@ export function PermissionModal({
<div <div
key={project.id} key={project.id}
className={cn( className={cn(
"flex items-center justify-between p-2 rounded-md cursor-pointer transition-colors", "flex items-center gap-2 p-2 rounded-md cursor-pointer transition-colors",
isSelected isSelected
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "hover:bg-muted" : "hover:bg-muted"
)} )}
onClick={() => handleToggleProject(key, project.id)} onClick={() => handleToggleProject(key, project.id)}
> >
<div className="truncate"> <Avatar className="h-6 w-6 shrink-0">
<div className="font-medium truncate">{project.title}</div> <AvatarImage src={project.username ? undefined : undefined} />
<AvatarFallback className="text-xs">
{project.title.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="truncate min-w-0 flex-1">
<div className="font-medium text-sm truncate">{project.title}</div>
{project.username && ( {project.username && (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground truncate">
@{project.username} @{project.username}
</div> </div>
)} )}
@@ -337,19 +390,42 @@ export function PermissionModal({
</div> </div>
{!isGlobal && currentProjectIds.length > 0 && ( {!isGlobal && currentProjectIds.length > 0 && (
<div className="flex flex-wrap gap-1 pl-8"> <div className="flex flex-wrap gap-1 ml-6">
{currentProjectIds.map((id) => { {currentProjectIds.map((id) => {
const project = getProjectById(id); const project = getProjectById(id);
return ( return (
<Badge <Badge
key={id} key={id}
variant="secondary" variant="secondary"
className="gap-1 text-xs" className={cn("gap-1 text-xs", isDisabled && "opacity-50")}
> >
{project?.title || "Unknown"} <Avatar className="h-4 w-4">
<AvatarFallback className="text-[10px]">
{project?.title.charAt(0).toUpperCase() || "?"}
</AvatarFallback>
</Avatar>
<span className="truncate max-w-[150px]">{project?.title || "Unknown"}</span>
<X <X
className="h-3 w-3 cursor-pointer hover:text-destructive" className={cn(
onClick={() => handleRemoveProject(key, id)} "h-3 w-3 ml-1",
isDisabled
? "cursor-not-allowed text-muted-foreground/50"
: "cursor-pointer hover:text-destructive"
)}
onClick={() => {
if (isDisabled) return;
setPermissions((prev) => {
const existing = prev.find((p) => p.key === key);
if (!existing) return prev;
const newProjectIds = existing.projectIds.filter((pid) => pid !== id);
if (newProjectIds.length === 0) {
return prev.filter((p) => p.key !== key);
}
return prev.map((p) =>
p.key === key ? { ...p, projectIds: newProjectIds } : p
);
});
}}
/> />
</Badge> </Badge>
); );
@@ -362,18 +438,30 @@ export function PermissionModal({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col"> <DialogContent className="max-w-3xl w-[98vw] max-h-[90vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" /> <Users className="h-5 w-5" />
Права доступа Права доступа
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Настройте права доступа для участника {member.user.username || `User ${member.user.telegram_id}`} Настройте права доступа для участника{" "}
<span className="font-medium">
{member.user.first_name || member.user.username || `User ${member.user.telegram_id}`}
</span>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4"> {isEditingOwnPermissions && isOnlyAdmin && (
<div className="flex items-center gap-2 text-sm text-amber-600 bg-amber-50 dark:bg-amber-950/20 p-3 rounded-md">
<Lock className="h-4 w-4 shrink-0" />
<span>
Вы не можете изменить свои права, так как вы единственный администратор воркспейса.
</span>
</div>
)}
<div className="flex-1 overflow-y-auto space-y-4 px-1">
<div className="flex flex-wrap gap-2 pb-2"> <div className="flex flex-wrap gap-2 pb-2">
<span className="text-sm text-muted-foreground mr-2">Быстрые настройки:</span> <span className="text-sm text-muted-foreground mr-2">Быстрые настройки:</span>
{PRESETS.map((preset) => ( {PRESETS.map((preset) => (
@@ -392,54 +480,47 @@ export function PermissionModal({
<Separator /> <Separator />
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> {GROUPS.slice(1).map((group) => {
<h4 className="text-sm font-semibold flex items-center gap-2"> const groupPermissions = Object.entries(PERMISSION_CONFIG)
👑 Администрирование .filter(([, config]) => config.group === group.key)
</h4> .map(([key, config]) => ({ ...config, key: key as PermissionKey }));
{PERMISSION_GROUPS.admin.map((key) => renderPermissionRow(key, "admin"))}
if (groupPermissions.length === 0) return null;
return (
<div key={group.key} className="space-y-2">
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground">
<group.icon className="h-4 w-4" />
{group.label}
</div>
<div className="space-y-3 pl-6 border-l-2 border-muted ml-2">
{groupPermissions.map((perm) => renderPermissionRow(perm.key))}
</div>
</div>
);
})}
</div>
<Separator />
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground">
<Shield className="h-4 w-4" />
Администрирование
</div> </div>
<div className="space-y-3 pl-6 border-l-2 border-muted ml-2">
<Separator /> {Object.entries(PERMISSION_CONFIG)
.filter(([, config]) => config.group === "admin")
<div className="space-y-2"> .map(([key, config]) => ({ ...config, key: key as PermissionKey }))
<h4 className="text-sm font-semibold flex items-center gap-2"> .map((perm) => renderPermissionRow(perm.key))}
📁 Проекты
</h4>
{PERMISSION_GROUPS.projects.map((key) => renderPermissionRow(key, "projects"))}
</div>
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold flex items-center gap-2">
🎨 Креативы
</h4>
{PERMISSION_GROUPS.creatives.map((key) => renderPermissionRow(key, "creatives"))}
</div>
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold flex items-center gap-2">
📋 Закупы
</h4>
{PERMISSION_GROUPS.placements.map((key) => renderPermissionRow(key, "placements"))}
</div>
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold flex items-center gap-2">
📊 Аналитика
</h4>
{PERMISSION_GROUPS.analytics.map((key) => renderPermissionRow(key, "analytics"))}
</div> </div>
</div> </div>
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 p-2 rounded"> <div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 p-3 rounded">
<Info className="h-4 w-4 shrink-0" /> <Info className="h-4 w-4 shrink-0" />
<span> <span>
Глобальные права применяются ко всему воркспейсу. Права на проекты применяются только к выбранным проектам. <strong>Полный доступ</strong> автоматически включает все остальные права.
Остальные права можно оставить для случая, если позже потребуется убрать полный доступ.
</span> </span>
</div> </div>
</div> </div>
@@ -449,10 +530,13 @@ export function PermissionModal({
{isDirty && <span className="text-primary">Есть несохранённые изменения</span>} {isDirty && <span className="text-primary">Есть несохранённые изменения</span>}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" onClick={handleReset} disabled={isSaving}> <Button variant="outline" onClick={handleReset} disabled={isSaving || (isEditingOwnPermissions && isOnlyAdmin)}>
Сбросить Сбросить
</Button> </Button>
<Button onClick={handleSave} disabled={!isDirty || isSaving}> <Button
onClick={handleSave}
disabled={!isDirty || isSaving || (isEditingOwnPermissions && isOnlyAdmin)}
>
{isSaving && <Loader2 className="h-4 w-4 animate-spin mr-2" />} {isSaving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Сохранить Сохранить
</Button> </Button>

View File

@@ -20,16 +20,19 @@ export const membersApi = {
{ params } { params }
), ),
/** /**
* Update member permissions * Update member permissions
*/ * @param workspaceId - workspace id
* @param workspaceUserId - workspace_user.id (not user.id!)
* @param data - permissions to update
*/
updatePermissions: ( updatePermissions: (
workspaceId: string, workspaceId: string,
userId: string, workspaceUserId: string,
data: UpdatePermissionsRequest data: UpdatePermissionsRequest
) => ) =>
api.put<WorkspaceMember>( api.put<WorkspaceMember>(
`/workspaces/${workspaceId}/members/${userId}/permissions`, `/workspaces/${workspaceId}/members/${workspaceUserId}/permissions`,
data data
), ),
}; };

View File

@@ -0,0 +1,72 @@
// ============================================================================
// API Error Types & Utilities
// ============================================================================
import { toast } from "sonner";
import type { ApiError } from "@/lib/types/api";
export interface PermissionErrorInfo {
isPermissionError: boolean;
message: string;
}
export function isPermissionError(error: ApiError): boolean {
return (
error.detail?.includes("access denied") ||
error.detail?.includes("доступ запрещён") ||
error.detail?.includes("недостаточно прав") ||
error.detail?.includes("403")
);
}
export function getPermissionErrorMessage(error: ApiError): string {
if (isPermissionError(error)) {
return "Недостаточно прав. Обратитесь к администратору.";
}
return error.detail || "Произошла ошибка";
}
export function handleApiError(error: unknown, customMessage?: string): ApiError {
const apiError = error as ApiError;
if (isPermissionError(apiError)) {
toast.error("Недостаточно прав", {
description: "Обратитесь к администратору workspace",
});
} else {
toast.error(customMessage || "Ошибка", {
description: apiError.detail || "Произошла ошибка при выполнении операции",
});
}
return apiError;
}
export function showPermissionErrorToast(): void {
toast.error("Недостаточно прав", {
description: "Обратитесь к администратору workspace",
duration: 5000,
});
}
export function showSuccessToast(message: string, description?: string): void {
toast.success(message, {
description,
duration: 4000,
});
}
export function showErrorToast(message: string, description?: string): void {
toast.error(message, {
description,
duration: 5000,
});
}
export function showLoadingToast(message: string): string | number {
return toast.loading(message);
}
export function dismissToast(id: string | number): void {
toast.dismiss(id);
}