feat: update purchase plans

This commit is contained in:
ivannoskov
2026-02-10 11:42:17 +03:00
parent 1826483750
commit d84f86a988
9 changed files with 1289 additions and 1134 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -30,14 +30,6 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -58,8 +50,8 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import type { WorkspaceMember, WorkspaceInvite, PermissionKey } from "@/lib/types/api"; import { PERMISSION_LABELS } from "@/lib/types/api";
import { PERMISSION_LABELS, ALL_PERMISSIONS } from "@/lib/types/api"; import { PermissionModal } from "@/components/ui/permission-modal";
// ============================================================================ // ============================================================================
// Component // Component
@@ -93,6 +85,12 @@ export default function SettingsPage() {
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Permission modal
const [showPermissionModal, setShowPermissionModal] = useState(false);
const [selectedMember, setSelectedMember] = useState<WorkspaceMember | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [loadingProjects, setLoadingProjects] = useState(false);
useEffect(() => { useEffect(() => {
if (currentWorkspace) { if (currentWorkspace) {
setName(currentWorkspace.name); setName(currentWorkspace.name);
@@ -119,6 +117,34 @@ export default function SettingsPage() {
} }
}; };
const loadProjects = async () => {
try {
setLoadingProjects(true);
const response = await workspacesApi.getProjects(workspaceId, { size: 100 });
setProjects(response.items);
} catch (err) {
console.error("Failed to load projects:", err);
} finally {
setLoadingProjects(false);
}
};
const openPermissionModal = async (member: WorkspaceMember) => {
setSelectedMember(member);
await loadProjects();
setShowPermissionModal(true);
};
const handlePermissionSave = async (
wsId: string,
memberId: string,
userId: string,
permissions: { key: PermissionKey; scopes?: { type: string; id: string }[] }[]
) => {
await membersApi.updatePermissions(wsId, userId, { permissions });
await loadMembers();
};
const handleUpdate = async () => { const handleUpdate = async () => {
if (!name.trim() || name === currentWorkspace?.name) return; if (!name.trim() || name === currentWorkspace?.name) return;
@@ -133,7 +159,7 @@ export default function SettingsPage() {
} }
}; };
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
@@ -152,82 +178,6 @@ export default function SettingsPage() {
} }
}; };
const handleAvatarDelete = async () => {
try {
setIsDeletingAvatar(true);
await workspacesApi.deleteAvatar(workspaceId);
await refreshWorkspaces();
} catch (err) {
console.error("Failed to delete avatar:", err);
} finally {
setIsDeletingAvatar(false);
}
};
const handleCreateInvite = async () => {
if (!username.trim()) return;
const cleanUsername = username.trim().replace(/^@/, "");
try {
setIsCreating(true);
setError(null);
const invite = await invitesApi.create(workspaceId, {
username: cleanUsername,
});
setInvites((prev) => [invite, ...prev]);
setShowInviteDialog(false);
setUsername("");
} catch (err: any) {
const errorMessage = err?.error?.message || err?.detail;
if (errorMessage?.includes("not found")) {
setError(`Пользователь @${cleanUsername} не зарегистрирован на платформе. Для получения приглашения необходимо зарегистрироваться.`);
} else {
setError(errorMessage || "Не удалось отправить приглашение");
}
}
};
const handlePermissionChange = async (
memberId: string,
userId: string,
permission: PermissionKey,
enabled: boolean
) => {
try {
setUpdating(memberId);
const member = members.find((m) => m.id === memberId);
if (!member) return;
let newPermissions = [...member.permissions];
if (enabled) {
if (!newPermissions.some((p) => p.key === permission)) {
newPermissions.push({ key: permission });
}
} else {
newPermissions = newPermissions.filter((p) => p.key !== permission);
}
await membersApi.updatePermissions(workspaceId, userId, {
permissions: newPermissions,
});
setMembers((prev) =>
prev.map((m) =>
m.id === memberId ? { ...m, permissions: newPermissions } : m
)
);
} catch (err) {
console.error("Failed to update permissions:", err);
} finally {
setUpdating(null);
}
};
const handleDelete = async () => { const handleDelete = async () => {
if (deleteConfirm !== `Удалить ${currentWorkspace?.name}`) return; if (deleteConfirm !== `Удалить ${currentWorkspace?.name}`) return;
@@ -248,6 +198,45 @@ export default function SettingsPage() {
return member.permissions.some((p) => p.key === key); return member.permissions.some((p) => p.key === key);
}; };
const renderPermissionBadges = (member: WorkspaceMember) => {
if (member.permissions.length === 0) {
return <span className="text-sm text-muted-foreground">Нет прав</span>;
}
const adminPerm = member.permissions.find((p) => p.key === "admin_full");
if (adminPerm) {
const isGlobal = !adminPerm.scopes || adminPerm.scopes.length === 0;
return (
<div className="flex flex-wrap gap-1">
<Badge variant="destructive" className="gap-1">
<Shield className="h-3 w-3" />
Админ {isGlobal ? "" : "(ограничен)"}
</Badge>
</div>
);
}
return (
<div className="flex flex-wrap gap-1">
{member.permissions.map((p) => {
const isGlobal = !p.scopes || p.scopes.length === 0;
const projectCount = p.scopes
? p.scopes.filter((s) => s.type === "project").length
: 0;
return (
<Badge key={p.key} variant="outline" className="gap-1">
{PERMISSION_LABELS[p.key]}
{!isGlobal && projectCount > 0 && (
<span className="text-muted-foreground">({projectCount})</span>
)}
</Badge>
);
})}
</div>
);
};
// Combine members and pending invites // Combine members and pending invites
const pendingInvites = invites.filter((inv) => inv.status === "pending"); const pendingInvites = invites.filter((inv) => inv.status === "pending");
const allPeople = [ const allPeople = [
@@ -533,76 +522,34 @@ export default function SettingsPage() {
)} )}
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge <Badge
variant={ variant={
member.status === "active" ? "default" : "secondary" member.status === "active" ? "default" : "secondary"
} }
>
{member.status === "active" ? "Активен" : "Приглашён"}
</Badge>
</TableCell>
<TableCell>
{renderPermissionBadges(member)}
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => openPermissionModal(member)}
disabled={updating === member.id}
> >
{member.status === "active" ? "Активен" : "Приглашён"} {updating === member.id ? (
</Badge> <Loader2 className="h-4 w-4 animate-spin" />
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{hasPermission(member, "admin_full") ? (
<Badge variant="destructive" className="gap-1">
<Shield className="h-3 w-3" />
Админ
</Badge>
) : ( ) : (
member.permissions.map((p) => ( <>
<Badge key={p.key} variant="outline"> Настроить
{PERMISSION_LABELS[p.key]} </>
</Badge>
))
)} )}
{member.permissions.length === 0 && ( </Button>
<span className="text-sm text-muted-foreground">
Нет прав
</span>
)}
</div>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={updating === member.id}
>
{updating === member.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
Права
<ChevronDown className="h-4 w-4 ml-1" />
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Права доступа</DropdownMenuLabel>
<DropdownMenuSeparator />
{ALL_PERMISSIONS.map((perm) => (
<DropdownMenuCheckboxItem
key={perm}
checked={hasPermission(member, perm)}
onCheckedChange={(checked) =>
handlePermissionChange(
member.id,
member.user.id,
perm,
checked
)
}
>
{PERMISSION_LABELS[perm]}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
@@ -681,6 +628,17 @@ export default function SettingsPage() {
</AlertDialog> </AlertDialog>
</CardContent> </CardContent>
</Card> </Card>
{selectedMember && (
<PermissionModal
open={showPermissionModal}
onOpenChange={setShowPermissionModal}
member={selectedMember}
projects={projects}
onSave={handlePermissionSave}
workspaceId={workspaceId}
/>
)}
</div> </div>
</> </>
); );

View File

@@ -0,0 +1,464 @@
"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";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
interface PermissionModalProps {
open: boolean;
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>;
workspaceId: string;
}
interface PermissionState {
key: PermissionKey;
isGlobal: boolean;
projectIds: string[];
}
const PRESETS = [
{ label: "Полный доступ", keys: ["admin_full"] },
{ label: "Только чтение", keys: ["projects_read", "creatives_read", "placements_read", "analytics_read"] },
{ label: "Менеджер", keys: ["projects_read", "projects_write", "placements_read", "placements_write"] },
{ label: "Убрать все", keys: [] },
];
export function PermissionModal({
open,
onOpenChange,
member,
projects,
onSave,
workspaceId,
}: PermissionModalProps) {
const [isSaving, setIsSaving] = useState(false);
const [permissions, setPermissions] = useState<PermissionState[]>([]);
const [projectSearch, setProjectSearch] = useState("");
useEffect(() => {
if (open && member) {
const perms: PermissionState[] = [];
for (const perm of member.permissions) {
const isGlobal = !perm.scopes || perm.scopes.length === 0;
const projectIds = perm.scopes
? perm.scopes.filter((s) => s.type === "project").map((s) => s.id)
: [];
perms.push({
key: perm.key,
isGlobal,
projectIds,
});
}
setPermissions(perms);
}
}, [open, member]);
const handleToggleGlobal = (key: PermissionKey) => {
setPermissions((prev) => {
const existing = prev.find((p) => p.key === key);
if (existing) {
return prev.map((p) =>
p.key === key ? { ...p, isGlobal: !p.isGlobal, projectIds: [] } : p
);
}
return [...prev, { key, isGlobal: true, projectIds: [] }];
});
};
const handleToggleProject = (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: toggleInArray(p.projectIds, projectId) }
: p
);
}
return [
...prev,
{ key, isGlobal: false, projectIds: [projectId] },
];
});
};
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);
}
return [...arr, item];
};
const applyPreset = (keys: PermissionKey[]) => {
if (keys.length === 0) {
setPermissions([]);
return;
}
setPermissions(
keys.map((key) => ({
key,
isGlobal: true,
projectIds: [],
}))
);
};
const getProjectById = (id: string): Project | undefined => {
return projects.find((p) => p.id === id);
};
const availableProjects = useMemo(() => {
return projects.filter(
(p) =>
p.title.toLowerCase().includes(projectSearch.toLowerCase()) ||
p.username?.toLowerCase().includes(projectSearch.toLowerCase())
);
}, [projects, projectSearch]);
const isDirty = useMemo(() => {
if (permissions.length !== member.permissions.length) return true;
for (const perm of permissions) {
const orig = member.permissions.find((p) => p.key === perm.key);
if (!orig) return true;
const origIsGlobal = !orig.scopes || orig.scopes.length === 0;
const origProjectIds = orig.scopes
? orig.scopes.filter((s) => s.type === "project").map((s) => s.id)
: [];
if (perm.isGlobal !== origIsGlobal) return true;
if (!perm.isGlobal) {
const permProjectSet = new Set(perm.projectIds);
const origProjectSet = new Set(origProjectIds);
if (permProjectSet.size !== origProjectSet.size) return true;
for (const id of permProjectSet) {
if (!origProjectSet.has(id)) return true;
}
}
}
return false;
}, [permissions, member.permissions]);
const handleSave = async () => {
setIsSaving(true);
try {
const result = permissions
.filter((p) => {
if (p.isGlobal) return true;
return p.projectIds.length > 0;
})
.map((p) => {
if (p.isGlobal) {
return { key: p.key };
}
return {
key: p.key,
scopes: p.projectIds.map((id) => ({ type: "project" as const, id })),
};
});
await onSave(workspaceId, member.id, member.user.id, result);
onOpenChange(false);
} finally {
setIsSaving(false);
}
};
const handleReset = () => {
const perms: PermissionState[] = [];
for (const perm of member.permissions) {
const isGlobal = !perm.scopes || perm.scopes.length === 0;
const projectIds = perm.scopes
? perm.scopes.filter((s) => s.type === "project").map((s) => s.id)
: [];
perms.push({
key: perm.key,
isGlobal,
projectIds,
});
}
setPermissions(perms);
};
const renderPermissionRow = (key: PermissionKey, group: string) => {
const current = permissions.find((p) => p.key === key);
const currentProjectIds = current?.projectIds || [];
const isGlobal = current?.isGlobal ?? false;
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}
</Badge>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant={isGlobal ? "default" : "outline"}
size="sm"
onClick={() => handleToggleGlobal(key)}
className="text-xs"
>
Глобально
</Button>
<Popover>
<PopoverTrigger asChild>
<Button variant={!isGlobal ? "default" : "outline"} size="sm" className="text-xs">
<Search className="h-3 w-3 mr-1" />
Проекты ({currentProjectIds.length})
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Input
placeholder="Поиск проекта..."
value={projectSearch}
onChange={(e) => setProjectSearch(e.target.value)}
className="h-8"
/>
</div>
<ScrollArea className="h-64">
<div className="space-y-1 pr-2">
{availableProjects.map((project) => {
const isSelected = currentProjectIds.includes(project.id);
return (
<div
key={project.id}
className={cn(
"flex items-center justify-between 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>
{project.username && (
<div className="text-xs text-muted-foreground">
@{project.username}
</div>
)}
</div>
{isSelected && <Check className="h-4 w-4 shrink-0" />}
</div>
);
})}
{availableProjects.length === 0 && (
<div className="text-center py-4 text-muted-foreground text-sm">
Проекты не найдены
</div>
)}
</div>
</ScrollArea>
</div>
</PopoverContent>
</Popover>
</div>
</div>
{!isGlobal && currentProjectIds.length > 0 && (
<div className="flex flex-wrap gap-1 pl-8">
{currentProjectIds.map((id) => {
const project = getProjectById(id);
return (
<Badge
key={id}
variant="secondary"
className="gap-1 text-xs"
>
{project?.title || "Unknown"}
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => handleRemoveProject(key, id)}
/>
</Badge>
);
})}
</div>
)}
</div>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Права доступа
</DialogTitle>
<DialogDescription>
Настройте права доступа для участника {member.user.username || `User ${member.user.telegram_id}`}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4">
<div className="flex flex-wrap gap-2 pb-2">
<span className="text-sm text-muted-foreground mr-2">Быстрые настройки:</span>
{PRESETS.map((preset) => (
<Button
key={preset.label}
variant="outline"
size="sm"
onClick={() => applyPreset(preset.keys as PermissionKey[])}
className="text-xs"
>
{preset.label}
</Button>
))}
</div>
<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"))}
</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>
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold flex items-center gap-2">
🎨 Креативы
</h4>
{PERMISSION_GROUPS.creatives.map((key) => renderPermissionRow(key, "creatives"))}
</div>
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold flex items-center gap-2">
📋 Закупы
</h4>
{PERMISSION_GROUPS.placements.map((key) => renderPermissionRow(key, "placements"))}
</div>
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold flex items-center gap-2">
📊 Аналитика
</h4>
{PERMISSION_GROUPS.analytics.map((key) => renderPermissionRow(key, "analytics"))}
</div>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 p-2 rounded">
<Info className="h-4 w-4 shrink-0" />
<span>
Глобальные права применяются ко всему воркспейсу. Права на проекты применяются только к выбранным проектам.
</span>
</div>
</div>
<DialogFooter className="flex justify-between items-center pt-4 border-t">
<div className="text-sm text-muted-foreground">
{isDirty && <span className="text-primary">Есть несохранённые изменения</span>}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleReset} disabled={isSaving}>
Сбросить
</Button>
<Button onClick={handleSave} disabled={!isDirty || isSaving}>
{isSaving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Сохранить
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:outline-hidden size-full rounded-[inherit]"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -7,6 +7,7 @@ import type {
Workspace, Workspace,
WorkspaceCreateRequest, WorkspaceCreateRequest,
WorkspaceUpdateRequest, WorkspaceUpdateRequest,
Project,
PaginatedResponse, PaginatedResponse,
PaginationParams, PaginationParams,
} from "@/lib/types/api"; } from "@/lib/types/api";
@@ -52,5 +53,11 @@ export const workspacesApi = {
*/ */
deleteAvatar: (workspaceId: string) => deleteAvatar: (workspaceId: string) =>
api.delete<Workspace>(`${BASE_PATH}/${workspaceId}/avatar`), api.delete<Workspace>(`${BASE_PATH}/${workspaceId}/avatar`),
/**
* Get projects for workspace
*/
getProjects: (workspaceId: string, params?: PaginationParams) =>
api.get<PaginatedResponse<Project>>(`${BASE_PATH}/${workspaceId}/projects`, { params }),
}; };

View File

@@ -21,51 +21,51 @@ export const COLUMN_GROUPS = [
] as const; ] as const;
export const ALL_COLUMNS: ColumnConfig[] = [ export const ALL_COLUMNS: ColumnConfig[] = [
// Basic // Basic - узкие колонки
{ id: "checkbox", label: "Чекбокс", group: "basic", editable: false, required: true }, { id: "checkbox", label: "", group: "basic", editable: false, required: true, width: "32px" },
{ id: "number", label: "Номер", group: "basic", editable: false, required: true }, { id: "number", label: "", group: "basic", editable: false, required: true, width: "40px" },
{ id: "status_deal", label: "Статус сделки", group: "basic", editable: true, partialEdit: true }, { id: "status_deal", label: "Статус сделки", group: "basic", editable: true, partialEdit: true, width: "minmax(120px, 1.2fr)" },
{ id: "status_post", label: "Статус поста", group: "basic", editable: true, partialEdit: true }, { id: "status_post", label: "Статус поста", group: "basic", editable: true, partialEdit: true, width: "minmax(120px, 1.2fr)" },
// Content // Content
{ id: "creative", label: "Креатив", group: "content", editable: true }, { id: "creative", label: "Креатив", group: "content", editable: true, width: "minmax(140px, 1.5fr)" },
{ id: "invite_link", label: "Пригл. ссылка", group: "content", editable: false }, { id: "invite_link", label: "Ссылка", group: "content", editable: false, width: "minmax(80px, 0.8fr)" },
{ id: "post_link", label: "Ссылка на пост", group: "content", editable: false }, { id: "post_link", label: "Пост", group: "content", editable: false, width: "minmax(50px, 0.5fr)" },
// Finance // Finance
{ id: "cost", label: "Стоимость", group: "finance", editable: true, partialEdit: true }, { id: "cost", label: "Стоимость", group: "finance", editable: true, partialEdit: true, width: "minmax(80px, 0.8fr)" },
{ id: "cost_before", label: "Стоимость до торга", group: "finance", editable: true }, { id: "cost_before", label: "До торга", group: "finance", editable: true, width: "minmax(80px, 0.8fr)" },
{ id: "cost_format", label: "Формат оплаты", group: "finance", editable: true, partialEdit: true }, { id: "cost_format", label: "Форм. оплаты", group: "finance", editable: true, partialEdit: true, width: "minmax(80px, 0.8fr)" },
{ id: "placement_type", label: "Тип закупа", group: "finance", editable: true }, { id: "placement_type", label: "Тип закупа", group: "finance", editable: true, width: "minmax(100px, 1fr)" },
{ id: "discount", label: "% скидки", group: "finance", editable: false }, { id: "discount", label: "Скидка", group: "finance", editable: false, width: "minmax(50px, 0.5fr)" },
{ id: "payment_date", label: "Дата оплаты", group: "finance", editable: true }, { id: "payment_date", label: "Оплата", group: "finance", editable: true, width: "minmax(90px, 0.8fr)" },
// Dates // Dates
{ id: "planned_date", label: "Плановая дата", group: "dates", editable: true }, { id: "planned_date", label: "План. выход", group: "dates", editable: true, width: "minmax(90px, 0.8fr)" },
{ id: "actual_date", label: "Фактическая дата", group: "dates", editable: false }, { id: "actual_date", label: "Факт", group: "dates", editable: false, width: "minmax(90px, 0.8fr)" },
{ id: "format", label: "Плановый формат", group: "dates", editable: true }, { id: "format", label: "Формат", group: "dates", editable: true, width: "minmax(100px, 0.8fr)" },
{ id: "time_top", label: "Время в топе", group: "dates", editable: false }, { id: "time_top", label: "В топе", group: "dates", editable: false, width: "minmax(60px, 0.5fr)" },
{ id: "time_in_feed", label: "Время в ленте", group: "dates", editable: false }, { id: "time_in_feed", label: "В ленте", group: "dates", editable: false, width: "minmax(60px, 0.5fr)" },
{ id: "link_created", label: "Дата ссылки", group: "dates", editable: false }, { id: "link_created", label: "Ссылка", group: "dates", editable: false, width: "minmax(80px, 0.7fr)" },
{ id: "post_deleted", label: "Дата удаления", group: "dates", editable: false }, { id: "post_deleted", label: "Удалён", group: "dates", editable: false, width: "minmax(80px, 0.7fr)" },
// Metrics // Metrics
{ id: "subscriptions", label: "Всего подписок", group: "metrics", editable: false }, { id: "subscriptions", label: "Подписки", group: "metrics", editable: false, width: "minmax(70px, 0.6fr)" },
{ id: "views", label: "Просмотры", group: "metrics", editable: false }, { id: "views", label: "Просмотры", group: "metrics", editable: false, width: "minmax(70px, 0.6fr)" },
// Calculated // Calculated
{ id: "cpf", label: "CPF", group: "calculated", editable: false }, { id: "cpf", label: "CPF", group: "calculated", editable: false, width: "minmax(50px, 0.5fr)" },
{ id: "cpm", label: "CPM", group: "calculated", editable: true, partialEdit: true }, { id: "cpm", label: "CPM", group: "calculated", editable: true, partialEdit: true, width: "minmax(50px, 0.5fr)" },
{ id: "conversion_24h", label: "Конверсия 24ч", group: "calculated", editable: false }, { id: "conversion_24h", label: "Конв. 24ч", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
{ id: "conversion_48h", label: "Конверсия 48ч", group: "calculated", editable: false }, { id: "conversion_48h", label: "Конв. 48ч", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
{ id: "conversion_total", label: "Конверсия общ.", group: "calculated", editable: false }, { id: "conversion_total", label: "Конв. общ.", group: "calculated", editable: false, width: "minmax(70px, 0.6fr)" },
{ id: "total_unsubs", label: "Всего отписок", group: "calculated", editable: false }, { id: "total_unsubs", label: "Отписки", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
{ id: "unsub_percent", label: "% отписок", group: "calculated", editable: false }, { id: "unsub_percent", label: "% отписок", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
{ id: "total_active", label: "Итого подписок", group: "calculated", editable: false }, { id: "total_active", label: "Итого", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
// Technical // Technical
{ id: "invite_type", label: "Тип ссылки", group: "technical", editable: true }, { id: "invite_type", label: "Тип ссылки", group: "technical", editable: true, width: "minmax(100px, 1fr)" },
{ id: "comment", label: "Комментарий", group: "technical", editable: true }, { id: "comment", label: "Комментарий", group: "technical", editable: true, width: "minmax(150px, 2fr)" },
] as const; ] as const;
// Default visible columns // Default visible columns
@@ -81,9 +81,9 @@ export const DEFAULT_VISIBLE_COLUMNS = new Set([
"cost_format", "cost_format",
"planned_date", "planned_date",
"format", "format",
"subscriptions", // "subscriptions",
"views", // "views",
"cpf", // "cpf",
"cpm", "cpm",
"comment", "comment",
]); ]);

205
lib/types/permission.ts Normal file
View File

@@ -0,0 +1,205 @@
import type { PermissionKey, Permission, PermissionScope, PermissionScopeType } from "./api";
export const PERMISSION_GROUPS = {
admin: ["admin_full"] as const,
projects: ["projects_read", "projects_write"] as const,
creatives: ["creatives_read", "creatives_write"] as const,
placements: ["placements_read", "placements_write"] as const,
analytics: ["analytics_read", "analytics_without_clicks", "analytics_own_creatives"] as const,
} as const;
export type PermissionGroup = keyof typeof PERMISSION_GROUPS;
export const PERMISSION_ICONS: Record<PermissionKey, string> = {
admin_full: "👑",
projects_read: "📁",
projects_write: "📁",
creatives_read: "🎨",
creatives_write: "🎨",
placements_read: "📋",
placements_write: "📋",
analytics_read: "📊",
analytics_without_clicks: "📊",
analytics_own_creatives: "📊",
};
export const PERMISSION_LABELS: Record<PermissionKey, string> = {
admin_full: "Полный доступ к администрированию",
projects_read: "Просмотр проектов",
projects_write: "Управление проектами",
creatives_read: "Просмотр креативов",
creatives_write: "Управление креативами",
placements_read: "Просмотр размещений",
placements_write: "Управление размещениями",
analytics_read: "Просмотр аналитики",
analytics_without_clicks: "Аналитика без кликов",
analytics_own_creatives: "Аналитика своих креативов",
};
export function hasGlobalPermission(permissions: Permission[], key: PermissionKey): boolean {
return permissions.some((p) => p.key === key && (!p.scopes || p.scopes.length === 0));
}
export function hasScopedPermission(
permissions: Permission[],
key: PermissionKey,
scopeType: PermissionScopeType,
scopeId: string
): boolean {
const perm = permissions.find((p) => p.key === key);
if (!perm || !perm.scopes) return false;
return perm.scopes.some((s) => s.type === scopeType && s.id === scopeId);
}
export function getScopesForPermission(
permissions: Permission[],
key: PermissionKey,
scopeType: PermissionScopeType
): string[] {
const perm = permissions.find((p) => p.key === key);
if (!perm || !perm.scopes) return [];
return perm.scopes.filter((s) => s.type === scopeType).map((s) => s.id);
}
export function addGlobalPermission(permissions: Permission[], key: PermissionKey): Permission[] {
const perm = permissions.find((p) => p.key === key);
if (perm) {
if (!perm.scopes || perm.scopes.length === 0) {
return permissions;
}
const newPerms = permissions.filter((p) => p.key !== key);
return [...newPerms, { key, scopes: undefined }];
}
return [...permissions, { key, scopes: undefined }];
}
export function removePermission(permissions: Permission[], key: PermissionKey): Permission[] {
return permissions.filter((p) => p.key !== key);
}
export function setScopedPermission(
permissions: Permission[],
key: PermissionKey,
scopeType: PermissionScopeType,
scopeIds: string[]
): Permission[] {
const existing = permissions.find((p) => p.key === key);
const otherPerms = permissions.filter((p) => p.key !== key);
if (scopeIds.length === 0) {
return otherPerms;
}
const existingScopes = existing?.scopes?.filter((s) => s.type !== scopeType) || [];
const newScopes = [...existingScopes, ...scopeIds.map((id) => ({ type: scopeType, id }))];
return [...otherPerms, { key, scopes: newScopes }];
}
export function toggleScopedPermission(
permissions: Permission[],
key: PermissionKey,
scopeType: PermissionScopeType,
scopeId: string
): Permission[] {
const existing = permissions.find((p) => p.key === key);
const otherPerms = permissions.filter((p) => p.key !== key);
if (!existing) {
return [...permissions, { key, scopes: [{ type: scopeType, id: scopeId }] }];
}
const scopes = existing.scopes || [];
const hasScope = scopes.some((s) => s.type === scopeType && s.id === scopeId);
if (hasScope) {
const filtered = scopes.filter((s) => !(s.type === scopeType && s.id === scopeId));
if (filtered.length === 0) {
return otherPerms;
}
return [...otherPerms, { key, scopes: filtered }];
}
return [...otherPerms, { key, scopes: [...scopes, { type: scopeType, id: scopeId }] }];
}
export function mergePermissions(
current: Permission[],
updates: Permission[]
): Permission[] {
const result = new Map<string, Permission>();
for (const perm of current) {
result.set(perm.key, perm);
}
for (const update of updates) {
const existing = result.get(update.key);
if (!existing) {
result.set(update.key, update);
} else if (!update.scopes || update.scopes.length === 0) {
result.set(update.key, update);
} else {
const existingScopes = existing.scopes || [];
const newScopes = new Map<string, PermissionScope>();
for (const scope of existingScopes) {
newScopes.set(`${scope.type}:${scope.id}`, scope);
}
for (const scope of update.scopes) {
newScopes.set(`${scope.type}:${scope.id}`, scope);
}
result.set(update.key, { key: update.key, scopes: Array.from(newScopes.values()) });
}
}
return Array.from(result.values());
}
export function calculatePermissionDiff(
original: Permission[],
updated: Permission[]
): { added: Permission[]; removed: Permission[]; modified: Permission[] } {
const added: Permission[] = [];
const removed: Permission[] = [];
const modified: Permission[] = [];
const originalKeys = new Set(original.map((p) => p.key));
const updatedKeys = new Set(updated.map((p) => p.key));
for (const perm of updated) {
if (!originalKeys.has(perm.key)) {
added.push(perm);
}
}
for (const perm of original) {
if (!updatedKeys.has(perm.key)) {
removed.push(perm);
}
}
for (const perm of updated) {
const orig = original.find((p) => p.key === perm.key);
if (orig && JSON.stringify(orig.scopes) !== JSON.stringify(perm.scopes)) {
modified.push(perm);
}
}
return { added, removed, modified };
}
export function isPermissionFullyGlobal(permissions: Permission[], key: PermissionKey): boolean {
const perm = permissions.find((p) => p.key === key);
return perm !== undefined && (!perm.scopes || perm.scopes.length === 0);
}
export function getAllScopeIdsForPermission(
permissions: Permission[],
key: PermissionKey,
scopeType: PermissionScopeType
): string[] {
const perm = permissions.find((p) => p.key === key);
if (!perm || !perm.scopes) return [];
return perm.scopes.filter((s) => s.type === scopeType).map((s) => s.id);
}

View File

@@ -24,6 +24,7 @@
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",

33
pnpm-lock.yaml generated
View File

@@ -53,6 +53,9 @@ importers:
'@radix-ui/react-radio-group': '@radix-ui/react-radio-group':
specifier: ^1.3.8 specifier: ^1.3.8
version: 1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-scroll-area':
specifier: ^1.2.2
version: 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-select': '@radix-ui/react-select':
specifier: ^2.2.6 specifier: ^2.2.6
version: 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -925,6 +928,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-scroll-area@1.2.10':
resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.2.6': '@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies: peerDependencies:
@@ -3692,6 +3708,23 @@ snapshots:
'@types/react': 19.2.2 '@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/react@19.2.2) '@types/react-dom': 19.2.2(@types/react@19.2.2)
'@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/react@19.2.2)
'@radix-ui/react-select@2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies: dependencies:
'@radix-ui/number': 1.1.1 '@radix-ui/number': 1.1.1