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

View File

@@ -4,7 +4,7 @@
// 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 Link from "next/link";
import {
@@ -19,6 +19,7 @@ import {
Info,
Bot,
Settings,
RefreshCw,
FolderKanban,
Archive,
RotateCcw,
@@ -60,6 +61,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Card,
CardContent,
@@ -86,6 +88,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { handleApiError, showPermissionErrorToast } from "@/lib/utils/error-handler";
import { BOT_USERNAME } from "@/lib/utils/constants";
import type { Project, SpendingAnalytics } from "@/lib/types/api";
@@ -143,48 +147,62 @@ const formatStatus = (status: string): string => {
export default function ProjectsPage() {
const params = useParams();
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 [viewMode, setViewMode] = useState<"grid" | "table">("grid");
const [settingsProject, setSettingsProject] = useState<Project | null>(null);
const [inviteLinkType, setInviteLinkType] = useState<"public" | "approval">("public");
const [isUpdatingSettings, setIsUpdatingSettings] = useState(false);
const [isArchiving, setIsArchiving] = useState(false);
const [isUnarchiving, setIsUnarchiving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteConfirmationText, setDeleteConfirmationText] = useState("");
// Move project state
const [showMoveDialog, setShowMoveDialog] = useState(false);
const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
const [isMoving, setIsMoving] = useState(false);
const [targetWorkspaceAdmin, setTargetWorkspaceAdmin] = useState(false);
const [isCheckingTargetPermissions, setIsCheckingTargetPermissions] = useState(false);
const { isDemoMode } = useWorkspace();
// 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.
const refreshProjectsRef = useRef(refreshProjects);
// Load projects with include_archived support
const loadProjects = useCallback(async () => {
try {
setIsLoadingProjects(true);
if (isDemoMode) {
const demoProjectsRaw = (await import("@/lib/demo")).demoProjects;
const projects = showArchived
? demoProjectsRaw
: demoProjectsRaw.filter(p => p.status === "active");
setLocalProjects(projects);
} else {
const response = await projectsApi.list(workspaceId, { size: 100, include_archived: showArchived });
setLocalProjects(response.items);
}
} catch (err) {
console.error("Failed to load projects:", err);
} finally {
setIsLoadingProjects(false);
}
}, [workspaceId, showArchived, isDemoMode]);
// Manual refresh - checks for new projects
const handleRefresh = async () => {
setIsRefreshing(true);
await loadProjects();
setIsRefreshing(false);
};
// Initial load and when showArchived changes
useEffect(() => {
refreshProjectsRef.current = refreshProjects;
}, [refreshProjects]);
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]);
loadProjects();
}, [loadProjects]);
const handleOpenSettings = (project: Project) => {
setSettingsProject(project);
@@ -194,69 +212,97 @@ export default function ProjectsPage() {
const handleSaveSettings = async () => {
if (!settingsProject) return;
try {
setIsUpdatingSettings(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.updateInviteLinkType(workspaceId, settingsProject.id, {
purchase_invite_type_default: inviteLinkType,
});
await refreshProjects();
setSettingsProject(null);
} catch (err) {
console.error("Failed to update settings:", err);
} finally {
setIsUpdatingSettings(false);
}
await toast.promise(
(async () => {
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.updateInviteLinkType(workspaceId, settingsProject.id, {
purchase_invite_type_default: inviteLinkType,
});
await loadProjects();
})(),
{
loading: "Сохранение настроек...",
success: () => {
setSettingsProject(null);
return "Настройки сохранены";
},
error: (err) => {
handleApiError(err);
return "Не удалось сохранить настройки";
},
}
);
};
const handleArchive = async () => {
if (!settingsProject) return;
try {
setIsArchiving(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.archive(workspaceId, settingsProject.id);
await refreshProjects();
setSettingsProject(null);
} catch (err) {
console.error("Failed to archive project:", err);
} finally {
setIsArchiving(false);
}
await toast.promise(
(async () => {
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.archive(workspaceId, settingsProject.id);
await loadProjects();
})(),
{
loading: "Архивирование проекта...",
success: () => {
setSettingsProject(null);
return `Проект «${settingsProject.title}» архивирован`;
},
error: (err) => {
handleApiError(err);
return "Не удалось архивировать проект";
},
}
);
};
const handleUnarchive = async () => {
if (!settingsProject) return;
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);
}
await toast.promise(
(async () => {
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.unarchive(workspaceId, settingsProject.id);
await loadProjects();
})(),
{
loading: "Восстановление проекта...",
success: () => {
setSettingsProject(null);
return `Проект «${settingsProject.title}» восстановлен`;
},
error: (err) => {
handleApiError(err);
return "Не удалось восстановить проект";
},
}
);
};
const handleDelete = async () => {
if (!settingsProject) return;
try {
setIsDeleting(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.delete(workspaceId, settingsProject.id);
await refreshProjects();
setSettingsProject(null);
setShowDeleteDialog(false);
setDeleteConfirmationText("");
} catch (err) {
console.error("Failed to delete project:", err);
} finally {
setIsDeleting(false);
}
await toast.promise(
(async () => {
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.delete(workspaceId, settingsProject.id);
await loadProjects();
})(),
{
loading: "Удаление проекта...",
success: () => {
setSettingsProject(null);
setShowDeleteDialog(false);
setDeleteConfirmationText("");
return `Проект «${settingsProject.title}» удалён`;
},
error: (err) => {
handleApiError(err);
return "Не удалось удалить проект";
},
}
);
};
const handleOpenDeleteDialog = () => {
@@ -312,20 +358,29 @@ export default function ProjectsPage() {
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 targetWorkspace = workspaces.find(w => w.id === targetWorkspaceId);
await toast.promise(
(async () => {
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.move(workspaceId, settingsProject.id, { target_workspace_id: targetWorkspaceId });
await loadProjects();
})(),
{
loading: "Перемещение проекта...",
success: () => {
setSettingsProject(null);
setShowMoveDialog(false);
setTargetWorkspaceId("");
setTargetWorkspaceAdmin(false);
return `Проект перемещён в «${targetWorkspace?.name}»`;
},
error: (err) => {
handleApiError(err);
return "Не удалось переместить проект";
},
}
);
};
const getChannelAvatar = (project: Project) => {
@@ -335,17 +390,30 @@ export default function ProjectsPage() {
return null;
};
// Check if user has any analytics permission
const canViewAnalytics = hasPermission("analytics_read") || hasPermission("analytics_without_clicks") || hasPermission("analytics_own_creatives");
// Check if user has subscriptions visibility
const canViewSubscriptions = hasPermission("analytics_read") || hasPermission("analytics_own_creatives");
// Check if user can edit projects
const canEditProjects = hasPermission("projects_write");
// Load statistics for each project
useEffect(() => {
if (projects.length === 0) {
if (localProjects.length === 0) {
setProjectsWithStats([]);
return;
}
if (!canViewAnalytics) {
setProjectsWithStats(localProjects.map(p => ({ ...p, stats: undefined })));
setLoadingStats(false);
return;
}
const loadStats = async () => {
setLoadingStats(true);
try {
const statsPromises = projects.map(async (project) => {
const statsPromises = localProjects.map(async (project) => {
try {
const spending = await demoAwareAnalyticsApi.spending(workspaceId, {
project_id: project.id,
@@ -376,17 +444,23 @@ export default function ProjectsPage() {
setProjectsWithStats(results);
} catch (err) {
console.error("Failed to load project stats:", err);
setProjectsWithStats(projects);
setProjectsWithStats(localProjects);
} finally {
setLoadingStats(false);
}
};
loadStats();
}, [projects, workspaceId]);
}, [localProjects, workspaceId, canViewAnalytics]);
const isLoading = isLoadingProjects || loadingStats;
// Filter projects based on archived status (already filtered by API, but keep for safety)
const filteredProjects = localProjectsWithStats.filter((project) => {
if (showArchived) return true;
return project.status !== "archived";
});
return (
<TooltipProvider>
<DashboardHeader
@@ -467,11 +541,33 @@ export default function ProjectsPage() {
</AlertDescription>
</Alert>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="show-archived"
checked={showArchived}
onCheckedChange={(checked) => setShowArchived(checked === true)}
/>
<Label htmlFor="show-archived" className="text-sm cursor-pointer">
Показать архивированные
</Label>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isLoadingProjects || isRefreshing}
>
<RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} />
Обновить
</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : projectsWithStats.length === 0 ? (
) : filteredProjects.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
@@ -533,7 +629,7 @@ export default function ProjectsPage() {
</TableRow>
</TableHeader>
<TableBody>
{projectsWithStats.map((project) => (
{filteredProjects.map((project) => (
<TableRow key={project.id}>
<TableCell>
<div className="flex items-center gap-3">
@@ -617,6 +713,7 @@ export default function ProjectsPage() {
variant="outline"
size="sm"
onClick={() => handleOpenSettings(project)}
disabled={!canEditProjects}
>
<Settings className="h-3 w-3" />
</Button>
@@ -629,7 +726,7 @@ export default function ProjectsPage() {
</Card>
) : (
<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">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
@@ -747,6 +844,7 @@ export default function ProjectsPage() {
variant="outline"
size="sm"
onClick={() => handleOpenSettings(project)}
disabled={!canEditProjects}
>
<Settings className="h-3.5 w-3.5" />
</Button>
@@ -792,47 +890,29 @@ export default function ProjectsPage() {
<Button
variant="outline"
onClick={handleUnarchive}
disabled={isUnarchiving || isDeleting || isUpdatingSettings}
disabled={!canEditProjects || isDemoMode}
className="w-full justify-start"
>
{isUnarchiving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Восстановление...
</>
) : (
<>
<RotateCcw className="h-4 w-4 mr-2" />
Восстановить из архива
</>
)}
<RotateCcw className="h-4 w-4 mr-2" />
Восстановить из архива
</Button>
) : (
<Button
variant="outline"
onClick={handleArchive}
disabled={isArchiving || isDeleting || isUpdatingSettings}
disabled={!canEditProjects || isDemoMode}
className="w-full justify-start"
>
{isArchiving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Архивирование...
</>
) : (
<>
<Archive className="h-4 w-4 mr-2" />
Архивировать проект
</>
)}
<Archive className="h-4 w-4 mr-2" />
Архивировать проект
</Button>
)}
<Button
variant="outline"
onClick={handleOpenMoveDialog}
disabled={!isAdmin || isArchiving || isDeleting || isUpdatingSettings || isUnarchiving}
disabled={!isAdmin || !canEditProjects || isDemoMode}
className="w-full justify-start"
title={!isAdmin ? "Вы должны быть администратором для перемещения проекта" : undefined}
title={!isAdmin ? "Вы должны быть администратором для перемещения проекта" : !canEditProjects ? "Нет прав на редактирование проектов" : undefined}
>
<ArrowRightFromLine className="h-4 w-4 mr-2" />
Переместить в другой воркспейс
@@ -840,7 +920,7 @@ export default function ProjectsPage() {
<Button
variant="destructive"
onClick={handleOpenDeleteDialog}
disabled={isArchiving || isDeleting || isUpdatingSettings || settingsProject?.status === "archived"}
disabled={!canEditProjects || isDemoMode || settingsProject?.status === "archived"}
className="w-full justify-start"
>
<Trash2 className="h-4 w-4 mr-2" />
@@ -858,16 +938,9 @@ export default function ProjectsPage() {
</Button>
<Button
onClick={handleSaveSettings}
disabled={isUpdatingSettings || isArchiving || isDeleting || isUnarchiving}
disabled={!canEditProjects || isDemoMode}
>
{isUpdatingSettings ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Сохранение...
</>
) : (
"Сохранить"
)}
Сохранить
</Button>
</DialogFooter>
</DialogContent>
@@ -897,17 +970,10 @@ export default function ProjectsPage() {
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting || deleteConfirmationText !== `Удалить ${settingsProject?.title}`}
disabled={deleteConfirmationText !== `Удалить ${settingsProject?.title}` || isDemoMode}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Удаление...
</>
) : (
"Удалить"
)}
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -978,19 +1044,10 @@ export default function ProjectsPage() {
</Button>
<Button
onClick={handleMoveProject}
disabled={!targetWorkspaceId || !targetWorkspaceAdmin || isMoving || isCheckingTargetPermissions}
disabled={!targetWorkspaceId || !targetWorkspaceAdmin || isDemoMode}
>
{isMoving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Перемещение...
</>
) : (
<>
<ArrowRightFromLine className="h-4 w-4 mr-2" />
Переместить
</>
)}
<ArrowRightFromLine className="h-4 w-4 mr-2" />
Переместить
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -83,6 +83,7 @@ import { PlacementStatusSelector } from "@/components/placement-status-selector"
import { PlacementPostStatusSelector } from "@/components/placement-post-status-selector";
import { PlacementWizard } from "@/components/placement-wizard";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import type {
Project,
Placement,
@@ -742,9 +743,12 @@ function CellRenderer({
);
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 (
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
{formatNumber(placement.placement_post?.subscriptions_count)}
{showSubs}
</td>
);
@@ -1076,7 +1080,7 @@ export default function PurchasePlanDetailPage() {
const searchParams = useSearchParams();
const workspaceId = params?.workspaceId as string;
const projectId = params?.projectId as string;
const { projects, hasPermission } = useWorkspace();
const { projects, hasPermission, canViewAnalytics, canViewSubscriptions } = useWorkspace();
const [placements, setPlacements] = useState<Placement[]>([]);
const [loading, setLoading] = useState(true);
@@ -1098,9 +1102,30 @@ export default function PurchasePlanDetailPage() {
const [showCopyDialog, setShowCopyDialog] = useState(false);
const [targetProjectId, setTargetProjectId] = useState<string | null>(null);
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);
// Creative selection dialog
@@ -1228,14 +1253,15 @@ export default function PurchasePlanDetailPage() {
const handleOpenCreativeSend = async (placement: PlacementWithStats) => {
if (!placement.creative_id || isDemoMode) return;
try {
await placementsApi.sendCreative(workspaceId, projectId, placement.id);
alert("Креатив успешно отправлен");
} catch (err) {
console.error("Failed to send creative:", err);
alert("Не удалось отправить креатив");
}
await toast.promise(
placementsApi.sendCreative(workspaceId, projectId, placement.id),
{
loading: "Отправка креатива...",
success: "Креатив успешно отправлен",
error: "Не удалось отправить креатив",
}
);
};
const handleSelectCreative = (creative: { id: string; name: string }) => {

View File

@@ -69,7 +69,7 @@ export default function PurchasePlansPage() {
const params = useParams();
const router = useRouter();
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 [loadingStats, setLoadingStats] = useState(true);
@@ -222,13 +222,17 @@ export default function PurchasePlansPage() {
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<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 className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<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 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" />
<span className="text-sm text-muted-foreground">CPF</span>
</div>
<span className="font-medium">{formatCurrency(summary.avg_cpf)}</span>
<span className="font-medium">
{canViewSubscriptions ? formatCurrency(summary.avg_cpf) : "—"}
</span>
</div>
</div>
</div>

View File

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

View File

@@ -4,12 +4,15 @@
// Workspace Settings Page
// ============================================================================
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useMemo } from "react";
import { useParams, useRouter } from "next/navigation";
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 { useWorkspace } from "@/components/providers/workspace-provider";
import { useAuth } from "@/components/auth/auth-provider";
import { workspacesApi, membersApi, invitesApi } from "@/lib/api";
import type { User } from "@/lib/types/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -51,7 +54,13 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
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 {
handleApiError,
showPermissionErrorToast,
showSuccessToast,
} from "@/lib/utils/error-handler";
// ============================================================================
// Component
@@ -62,6 +71,8 @@ export default function SettingsPage() {
const router = useRouter();
const workspaceId = params?.workspaceId as string;
const { currentWorkspace, refreshWorkspaces } = useWorkspace();
const { user } = useAuth();
const currentUser = user;
const [name, setName] = useState(currentWorkspace?.name || "");
const [isUpdating, setIsUpdating] = useState(false);
@@ -91,6 +102,14 @@ export default function SettingsPage() {
const [projects, setProjects] = useState<Project[]>([]);
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(() => {
if (currentWorkspace) {
setName(currentWorkspace.name);
@@ -139,59 +158,142 @@ export default function SettingsPage() {
wsId: string,
memberId: string,
userId: string,
permissions: { key: PermissionKey; scopes?: { type: string; id: string }[] }[]
permissions: { key: PermissionKey; scopes?: PermissionScope[] }[]
) => {
await membersApi.updatePermissions(wsId, userId, { permissions });
await loadMembers();
await toast.promise(
membersApi.updatePermissions(wsId, memberId, { permissions }),
{
loading: "Сохранение прав доступа...",
success: () => {
loadMembers();
return "Права доступа обновлены";
},
error: (err) => {
handleApiError(err);
return "Не удалось сохранить права";
},
}
);
setShowPermissionModal(false);
};
const handleUpdate = async () => {
if (!name.trim() || name === currentWorkspace?.name) return;
try {
setIsUpdating(true);
await workspacesApi.update(workspaceId, { name: name.trim() });
await refreshWorkspaces();
} catch (err) {
console.error("Failed to update workspace:", err);
} finally {
setIsUpdating(false);
}
await toast.promise(
workspacesApi.update(workspaceId, { name: name.trim() }),
{
loading: "Сохранение названия...",
success: () => {
refreshWorkspaces();
return "Название обновлено";
},
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];
if (!file) return;
try {
setIsUploadingAvatar(true);
const updatedWorkspace = await workspacesApi.uploadAvatar(workspaceId, file);
await refreshWorkspaces();
if (fileInputRef.current) {
fileInputRef.current.value = "";
await toast.promise(
workspacesApi.uploadAvatar(workspaceId, file),
{
loading: "Загрузка аватара...",
success: () => {
refreshWorkspaces();
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setAvatarFile(null);
return "Аватар загружен";
},
error: (err) => {
handleApiError(err);
return "Не удалось загрузить аватар";
},
}
setAvatarFile(null);
} catch (err) {
console.error("Failed to upload avatar:", err);
} finally {
setIsUploadingAvatar(false);
}
);
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 () => {
if (deleteConfirm !== `Удалить ${currentWorkspace?.name}`) return;
try {
setIsDeleting(true);
await workspacesApi.delete(workspaceId);
router.replace("/");
} catch (err) {
console.error("Failed to delete workspace:", err);
} finally {
setIsDeleting(false);
setShowDeleteDialog(false);
setDeleteConfirm("");
}
await toast.promise(
workspacesApi.delete(workspaceId),
{
loading: "Удаление воркспейса...",
success: () => {
router.replace("/");
return "Воркспейс удалён";
},
error: (err) => {
handleApiError(err);
return "Не удалось удалить воркспейс";
},
}
);
setIsDeleting(false);
setShowDeleteDialog(false);
setDeleteConfirm("");
};
const hasPermission = (member: WorkspaceMember, key: PermissionKey) => {
@@ -522,20 +624,20 @@ const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
)}
</div>
</div>
</TableCell>
<TableCell>
<Badge
variant={
member.status === "active" ? "default" : "secondary"
}
>
{member.status === "active" ? "Активен" : "Приглашён"}
</Badge>
</TableCell>
<TableCell>
{renderPermissionBadges(member)}
</TableCell>
<TableCell>
</TableCell>
<TableCell>
<Badge
variant={
member.status === "active" ? "default" : "secondary"
}
>
{member.status === "active" ? "Активен" : "Приглашён"}
</Badge>
</TableCell>
<TableCell>
{renderPermissionBadges(member)}
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
@@ -637,6 +739,8 @@ const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
projects={projects}
onSave={handlePermissionSave}
workspaceId={workspaceId}
currentUserId={currentUser?.id || ""}
isOnlyAdmin={isOnlyAdmin}
/>
)}
</div>

View File

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