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