feat: update permissions
This commit is contained in:
@@ -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 {
|
||||
await toast.promise(
|
||||
(async () => {
|
||||
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 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 {
|
||||
const handleDelete = async (creativeId: string, creativeName: string) => {
|
||||
await toast.promise(
|
||||
(async () => {
|
||||
await creativesApi.delete(workspaceId, creativeId);
|
||||
loadCreatives();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete creative:", err);
|
||||
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,6 +418,7 @@ export default function CreativesPage() {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableHead>
|
||||
{canViewSubscriptions && (
|
||||
<TableHead className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
Подписчики
|
||||
@@ -400,6 +432,7 @@ export default function CreativesPage() {
|
||||
</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 ? (
|
||||
{canViewAnalytics ? (
|
||||
loadingAnalytics ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin inline" />
|
||||
) : (
|
||||
formatCurrency(creative.analytics?.total_cost)
|
||||
)
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
{canViewSubscriptions && (
|
||||
<TableCell className="text-right">
|
||||
{loadingAnalytics ? (
|
||||
{canViewAnalytics ? (
|
||||
loadingAnalytics ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin inline" />
|
||||
) : (
|
||||
formatNumber(creative.analytics?.total_subscriptions)
|
||||
)
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-right">
|
||||
{loadingAnalytics ? (
|
||||
{canViewAnalytics ? (
|
||||
loadingAnalytics ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin inline" />
|
||||
) : (
|
||||
formatCurrency(creative.analytics?.avg_cpf)
|
||||
)
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -487,7 +536,7 @@ 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"
|
||||
@@ -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>
|
||||
{canViewAnalytics && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
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" />
|
||||
Аналитика
|
||||
</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,6 +650,7 @@ export default function CreativesPage() {
|
||||
<div className="text-xs text-muted-foreground">Потрачено</div>
|
||||
</div>
|
||||
</div>
|
||||
{canViewSubscriptions && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
@@ -614,6 +660,7 @@ export default function CreativesPage() {
|
||||
<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>
|
||||
|
||||
@@ -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);
|
||||
useEffect(() => {
|
||||
refreshProjectsRef.current = refreshProjects;
|
||||
}, [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]);
|
||||
|
||||
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);
|
||||
// Manual refresh - checks for new projects
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await loadProjects();
|
||||
setIsRefreshing(false);
|
||||
};
|
||||
}, [workspaceId, isDemoMode]);
|
||||
|
||||
// Initial load and when showArchived changes
|
||||
useEffect(() => {
|
||||
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);
|
||||
await toast.promise(
|
||||
(async () => {
|
||||
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
||||
await api.updateInviteLinkType(workspaceId, settingsProject.id, {
|
||||
purchase_invite_type_default: inviteLinkType,
|
||||
});
|
||||
await refreshProjects();
|
||||
await loadProjects();
|
||||
})(),
|
||||
{
|
||||
loading: "Сохранение настроек...",
|
||||
success: () => {
|
||||
setSettingsProject(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to update settings:", err);
|
||||
} finally {
|
||||
setIsUpdatingSettings(false);
|
||||
return "Настройки сохранены";
|
||||
},
|
||||
error: (err) => {
|
||||
handleApiError(err);
|
||||
return "Не удалось сохранить настройки";
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
if (!settingsProject) return;
|
||||
|
||||
try {
|
||||
setIsArchiving(true);
|
||||
await toast.promise(
|
||||
(async () => {
|
||||
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
||||
await api.archive(workspaceId, settingsProject.id);
|
||||
await refreshProjects();
|
||||
await loadProjects();
|
||||
})(),
|
||||
{
|
||||
loading: "Архивирование проекта...",
|
||||
success: () => {
|
||||
setSettingsProject(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to archive project:", err);
|
||||
} finally {
|
||||
setIsArchiving(false);
|
||||
return `Проект «${settingsProject.title}» архивирован`;
|
||||
},
|
||||
error: (err) => {
|
||||
handleApiError(err);
|
||||
return "Не удалось архивировать проект";
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleUnarchive = async () => {
|
||||
if (!settingsProject) return;
|
||||
|
||||
try {
|
||||
setIsUnarchiving(true);
|
||||
await toast.promise(
|
||||
(async () => {
|
||||
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
||||
await api.unarchive(workspaceId, settingsProject.id);
|
||||
await refreshProjects();
|
||||
await loadProjects();
|
||||
})(),
|
||||
{
|
||||
loading: "Восстановление проекта...",
|
||||
success: () => {
|
||||
setSettingsProject(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to unarchive project:", err);
|
||||
} finally {
|
||||
setIsUnarchiving(false);
|
||||
return `Проект «${settingsProject.title}» восстановлен`;
|
||||
},
|
||||
error: (err) => {
|
||||
handleApiError(err);
|
||||
return "Не удалось восстановить проект";
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!settingsProject) return;
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await toast.promise(
|
||||
(async () => {
|
||||
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
||||
await api.delete(workspaceId, settingsProject.id);
|
||||
await refreshProjects();
|
||||
await loadProjects();
|
||||
})(),
|
||||
{
|
||||
loading: "Удаление проекта...",
|
||||
success: () => {
|
||||
setSettingsProject(null);
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteConfirmationText("");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete project:", err);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
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 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 refreshProjects();
|
||||
await loadProjects();
|
||||
})(),
|
||||
{
|
||||
loading: "Перемещение проекта...",
|
||||
success: () => {
|
||||
setSettingsProject(null);
|
||||
setShowMoveDialog(false);
|
||||
setTargetWorkspaceId("");
|
||||
setTargetWorkspaceAdmin(false);
|
||||
} catch (err) {
|
||||
console.error("Failed to move project:", err);
|
||||
} finally {
|
||||
setIsMoving(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" />
|
||||
Восстановить из архива
|
||||
</>
|
||||
)}
|
||||
</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" />
|
||||
Архивировать проект
|
||||
</>
|
||||
)}
|
||||
</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" />
|
||||
Переместить
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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
|
||||
@@ -1229,13 +1254,14 @@ 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 }) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
await toast.promise(
|
||||
(async () => {
|
||||
const invite = await invitesApi.create(workspaceId, {
|
||||
username: cleanUsername,
|
||||
});
|
||||
|
||||
setInvites((prev) => [invite, ...prev]);
|
||||
setShowDialog(false);
|
||||
setUsername("");
|
||||
} catch (err: any) {
|
||||
return invite;
|
||||
})(),
|
||||
{
|
||||
loading: "Отправка приглашения...",
|
||||
success: `Приглашение отправлено @${cleanUsername}`,
|
||||
error: (err) => {
|
||||
const errorMessage = err?.error?.message || err?.detail;
|
||||
if (errorMessage?.includes("not found")) {
|
||||
setError(`Пользователь @${cleanUsername} не зарегистрирован на платформе. Для получения приглашения необходимо зарегистрироваться.`);
|
||||
} else {
|
||||
setError(errorMessage || "Не удалось отправить приглашение");
|
||||
return `Пользователь @${cleanUsername} не зарегистрирован на платформе`;
|
||||
}
|
||||
return errorMessage || "Не удалось отправить приглашение";
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: WorkspaceInvite["status"]) => {
|
||||
@@ -176,11 +176,6 @@ export default function InvitesPage() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -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" />
|
||||
)}
|
||||
Отправить
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -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();
|
||||
await toast.promise(
|
||||
workspacesApi.uploadAvatar(workspaceId, file),
|
||||
{
|
||||
loading: "Загрузка аватара...",
|
||||
success: () => {
|
||||
refreshWorkspaces();
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
setAvatarFile(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to upload avatar:", err);
|
||||
} finally {
|
||||
setIsUploadingAvatar(false);
|
||||
return "Аватар загружен";
|
||||
},
|
||||
error: (err) => {
|
||||
handleApiError(err);
|
||||
return "Не удалось загрузить аватар";
|
||||
},
|
||||
}
|
||||
);
|
||||
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);
|
||||
await toast.promise(
|
||||
workspacesApi.delete(workspaceId),
|
||||
{
|
||||
loading: "Удаление воркспейса...",
|
||||
success: () => {
|
||||
router.replace("/");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete workspace:", err);
|
||||
} finally {
|
||||
return "Воркспейс удалён";
|
||||
},
|
||||
error: (err) => {
|
||||
handleApiError(err);
|
||||
return "Не удалось удалить воркспейс";
|
||||
},
|
||||
}
|
||||
);
|
||||
setIsDeleting(false);
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteConfirm("");
|
||||
}
|
||||
};
|
||||
|
||||
const hasPermission = (member: WorkspaceMember, key: PermissionKey) => {
|
||||
@@ -522,7 +624,7 @@ const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
@@ -637,6 +739,8 @@ const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
projects={projects}
|
||||
onSave={handlePermissionSave}
|
||||
workspaceId={workspaceId}
|
||||
currentUserId={currentUser?.id || ""}
|
||||
isOnlyAdmin={isOnlyAdmin}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -30,7 +31,7 @@ export const ProjectSelector: React.FC = () => {
|
||||
|
||||
if (isLoadingProjects) {
|
||||
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 getAvatarUrl = (project: typeof projects[0]) => {
|
||||
return project.username
|
||||
? `https://t.me/i/userpic/320/${project.username}.jpg`
|
||||
: null;
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
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 ? (
|
||||
<span className="text-muted-foreground">Выберите проекты</span>
|
||||
<>
|
||||
<span className="text-muted-foreground">Все проекты</span>
|
||||
</>
|
||||
) : selectedProjects.length === 1 ? (
|
||||
<>
|
||||
<span className="font-semibold text-base truncate max-w-[220px]">
|
||||
<Avatar className="h-6 w-6 shrink-0">
|
||||
<AvatarImage src={getAvatarUrl(selectedProjects[0]) || undefined} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{selectedProjects[0].title.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate max-w-[180px]">
|
||||
{selectedProjects[0].title}
|
||||
</span>
|
||||
{selectedProjects[0].username && (
|
||||
<span className="text-xs text-muted-foreground -mt-0.5">
|
||||
@{selectedProjects[0].username}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : allSelected ? (
|
||||
<span className="font-semibold text-base">Все проекты</span>
|
||||
<>
|
||||
<span className="font-medium">Все проекты</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-semibold text-base">
|
||||
<Avatar className="h-6 w-6 shrink-0">
|
||||
<AvatarFallback className="text-xs">
|
||||
{selectedProjects.length}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate max-w-[180px]">
|
||||
{selectedProjects.length} проекта
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground -mt-0.5 truncate max-w-[220px]">
|
||||
{selectedProjects.map((p) => p.title).join(", ")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 ml-auto" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-[280px] p-2" align="start">
|
||||
<DropdownMenuContent className="w-[240px] p-2" align="start">
|
||||
{/* Quick actions */}
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Button
|
||||
@@ -117,7 +129,7 @@ export const ProjectSelector: React.FC = () => {
|
||||
<div
|
||||
key={project.id}
|
||||
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"
|
||||
)}
|
||||
onClick={() => !isOnlyOne && toggleProject(project)}
|
||||
@@ -128,15 +140,13 @@ export const ProjectSelector: React.FC = () => {
|
||||
onCheckedChange={() => !isOnlyOne && toggleProject(project)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<Tv className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="font-medium truncate">{project.title}</span>
|
||||
{project.username && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@{project.username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Avatar className="h-5 w-5 shrink-0">
|
||||
<AvatarImage src={getAvatarUrl(project) || undefined} />
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{project.title.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate text-sm">{project.title}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -10,6 +10,7 @@ import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useParams, useRouter, usePathname } from "next/navigation";
|
||||
import { workspacesApi, projectsApi, membersApi, authApi } from "@/lib/api";
|
||||
@@ -57,6 +58,10 @@ interface WorkspaceContextType {
|
||||
isAdmin: boolean;
|
||||
// Check if user is admin in a specific workspace
|
||||
isWorkspaceAdmin: (workspaceId: string) => Promise<boolean>;
|
||||
// Analytics permission helpers (memoized values, not functions)
|
||||
hasAnyAnalyticsPermission: boolean;
|
||||
canViewSubscriptions: boolean;
|
||||
canViewAnalytics: boolean;
|
||||
|
||||
// Loading states
|
||||
isLoading: boolean;
|
||||
@@ -182,6 +187,32 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
|
||||
[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
|
||||
// ============================================================================
|
||||
@@ -519,6 +550,9 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
|
||||
hasPermission,
|
||||
isAdmin,
|
||||
isWorkspaceAdmin,
|
||||
hasAnyAnalyticsPermission,
|
||||
canViewSubscriptions,
|
||||
canViewAnalytics,
|
||||
|
||||
// Loading states
|
||||
isLoading,
|
||||
|
||||
@@ -60,7 +60,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
"use client";
|
||||
|
||||
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 {
|
||||
hasGlobalPermission,
|
||||
hasScopedPermission,
|
||||
getScopesForPermission,
|
||||
setScopedPermission,
|
||||
toggleScopedPermission,
|
||||
removePermission,
|
||||
calculatePermissionDiff,
|
||||
} from "@/lib/types/permission";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
Loader2,
|
||||
Shield,
|
||||
Search,
|
||||
X,
|
||||
Check,
|
||||
Info,
|
||||
Crown,
|
||||
FolderOpen,
|
||||
Palette,
|
||||
ClipboardList,
|
||||
BarChart3,
|
||||
Users,
|
||||
Lock,
|
||||
Eye,
|
||||
Pencil,
|
||||
ChartArea,
|
||||
UserRoundX,
|
||||
FileUser,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import type { WorkspaceMember, Project, PermissionKey, PermissionScope } from "@/lib/types/api";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -31,7 +36,12 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} 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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PermissionModalProps {
|
||||
@@ -39,8 +49,10 @@ interface PermissionModalProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
member: WorkspaceMember;
|
||||
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;
|
||||
currentUserId: string;
|
||||
isOnlyAdmin: boolean;
|
||||
}
|
||||
|
||||
interface PermissionState {
|
||||
@@ -49,6 +61,27 @@ interface PermissionState {
|
||||
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 = [
|
||||
{ label: "Полный доступ", keys: ["admin_full"] },
|
||||
{ label: "Только чтение", keys: ["projects_read", "creatives_read", "placements_read", "analytics_read"] },
|
||||
@@ -63,6 +96,8 @@ export function PermissionModal({
|
||||
projects,
|
||||
onSave,
|
||||
workspaceId,
|
||||
currentUserId,
|
||||
isOnlyAdmin,
|
||||
}: PermissionModalProps) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [permissions, setPermissions] = useState<PermissionState[]>([]);
|
||||
@@ -89,7 +124,18 @@ export function PermissionModal({
|
||||
}
|
||||
}, [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) => {
|
||||
if (hasGlobalAdmin && key !== "admin_full") return;
|
||||
|
||||
setPermissions((prev) => {
|
||||
const existing = prev.find((p) => p.key === key);
|
||||
if (existing) {
|
||||
@@ -102,6 +148,8 @@ export function PermissionModal({
|
||||
};
|
||||
|
||||
const handleToggleProject = (key: PermissionKey, projectId: string) => {
|
||||
if (hasGlobalAdmin && key !== "admin_full") return;
|
||||
|
||||
setPermissions((prev) => {
|
||||
const existing = prev.find((p) => p.key === key);
|
||||
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[] => {
|
||||
if (arr.includes(item)) {
|
||||
return arr.filter((i) => i !== item);
|
||||
@@ -255,35 +269,68 @@ export function PermissionModal({
|
||||
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 currentProjectIds = current?.projectIds || [];
|
||||
const isGlobal = current?.isGlobal ?? false;
|
||||
const isDisabled = isPermissionDisabled(key);
|
||||
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-lg">{PERMISSION_ICONS[key]}</span>
|
||||
<span className="font-medium truncate">{PERMISSION_LABELS[key]}</span>
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{group}
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
"space-y-2 transition-opacity",
|
||||
isDisabled && "opacity-50"
|
||||
)}
|
||||
>
|
||||
<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 className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant={isGlobal ? "default" : "outline"}
|
||||
size="sm"
|
||||
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>
|
||||
<Popover>
|
||||
<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" />
|
||||
Проекты ({currentProjectIds.length})
|
||||
{currentProjectIds.length > 0
|
||||
? `${currentProjectIds.length} проект${currentProjectIds.length === 1 ? "" : currentProjectIds.length > 4 ? "ов" : "а"}`
|
||||
: "Проекты"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80" align="end">
|
||||
@@ -304,17 +351,23 @@ export function PermissionModal({
|
||||
<div
|
||||
key={project.id}
|
||||
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
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
onClick={() => handleToggleProject(key, project.id)}
|
||||
>
|
||||
<div className="truncate">
|
||||
<div className="font-medium truncate">{project.title}</div>
|
||||
<Avatar className="h-6 w-6 shrink-0">
|
||||
<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 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
@{project.username}
|
||||
</div>
|
||||
)}
|
||||
@@ -337,19 +390,42 @@ export function PermissionModal({
|
||||
</div>
|
||||
|
||||
{!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) => {
|
||||
const project = getProjectById(id);
|
||||
return (
|
||||
<Badge
|
||||
key={id}
|
||||
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
|
||||
className="h-3 w-3 cursor-pointer hover:text-destructive"
|
||||
onClick={() => handleRemoveProject(key, id)}
|
||||
className={cn(
|
||||
"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>
|
||||
);
|
||||
@@ -362,18 +438,30 @@ export function PermissionModal({
|
||||
|
||||
return (
|
||||
<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>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
<Users className="h-5 w-5" />
|
||||
Права доступа
|
||||
</DialogTitle>
|
||||
<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>
|
||||
</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">
|
||||
<span className="text-sm text-muted-foreground mr-2">Быстрые настройки:</span>
|
||||
{PRESETS.map((preset) => (
|
||||
@@ -392,54 +480,47 @@ export function PermissionModal({
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-2">
|
||||
👑 Администрирование
|
||||
</h4>
|
||||
{PERMISSION_GROUPS.admin.map((key) => renderPermissionRow(key, "admin"))}
|
||||
{GROUPS.slice(1).map((group) => {
|
||||
const groupPermissions = Object.entries(PERMISSION_CONFIG)
|
||||
.filter(([, config]) => config.group === group.key)
|
||||
.map(([key, config]) => ({ ...config, key: key as PermissionKey }));
|
||||
|
||||
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">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-2">
|
||||
📁 Проекты
|
||||
</h4>
|
||||
{PERMISSION_GROUPS.projects.map((key) => renderPermissionRow(key, "projects"))}
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground">
|
||||
<Shield className="h-4 w-4" />
|
||||
Администрирование
|
||||
</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 className="space-y-3 pl-6 border-l-2 border-muted ml-2">
|
||||
{Object.entries(PERMISSION_CONFIG)
|
||||
.filter(([, config]) => config.group === "admin")
|
||||
.map(([key, config]) => ({ ...config, key: key as PermissionKey }))
|
||||
.map((perm) => renderPermissionRow(perm.key))}
|
||||
</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" />
|
||||
<span>
|
||||
Глобальные права применяются ко всему воркспейсу. Права на проекты применяются только к выбранным проектам.
|
||||
<strong>Полный доступ</strong> автоматически включает все остальные права.
|
||||
Остальные права можно оставить для случая, если позже потребуется убрать полный доступ.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -449,10 +530,13 @@ export function PermissionModal({
|
||||
{isDirty && <span className="text-primary">Есть несохранённые изменения</span>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleReset} disabled={isSaving}>
|
||||
<Button variant="outline" onClick={handleReset} disabled={isSaving || (isEditingOwnPermissions && isOnlyAdmin)}>
|
||||
Сбросить
|
||||
</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" />}
|
||||
Сохранить
|
||||
</Button>
|
||||
|
||||
@@ -20,16 +20,19 @@ export const membersApi = {
|
||||
{ params }
|
||||
),
|
||||
|
||||
/**
|
||||
/**
|
||||
* Update member permissions
|
||||
* @param workspaceId - workspace id
|
||||
* @param workspaceUserId - workspace_user.id (not user.id!)
|
||||
* @param data - permissions to update
|
||||
*/
|
||||
updatePermissions: (
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
workspaceUserId: string,
|
||||
data: UpdatePermissionsRequest
|
||||
) =>
|
||||
api.put<WorkspaceMember>(
|
||||
`/workspaces/${workspaceId}/members/${userId}/permissions`,
|
||||
`/workspaces/${workspaceId}/members/${workspaceUserId}/permissions`,
|
||||
data
|
||||
),
|
||||
};
|
||||
|
||||
72
lib/utils/error-handler.ts
Normal file
72
lib/utils/error-handler.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user