feat: update permissions
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user