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

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>
);
}