feat: update purchase plans
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
464
components/ui/permission-modal.tsx
Normal file
464
components/ui/permission-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
components/ui/scroll-area.tsx
Normal file
58
components/ui/scroll-area.tsx
Normal 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 }
|
||||||
@@ -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 }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
205
lib/types/permission.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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
33
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user