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,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -58,8 +50,8 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import type { WorkspaceMember, WorkspaceInvite, PermissionKey } from "@/lib/types/api";
|
||||
import { PERMISSION_LABELS, ALL_PERMISSIONS } from "@/lib/types/api";
|
||||
import { PERMISSION_LABELS } from "@/lib/types/api";
|
||||
import { PermissionModal } from "@/components/ui/permission-modal";
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
@@ -93,6 +85,12 @@ export default function SettingsPage() {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
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(() => {
|
||||
if (currentWorkspace) {
|
||||
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 () => {
|
||||
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];
|
||||
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 () => {
|
||||
if (deleteConfirm !== `Удалить ${currentWorkspace?.name}`) return;
|
||||
|
||||
@@ -248,6 +198,45 @@ export default function SettingsPage() {
|
||||
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
|
||||
const pendingInvites = invites.filter((inv) => inv.status === "pending");
|
||||
const allPeople = [
|
||||
@@ -533,76 +522,34 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
member.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
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" ? "Активен" : "Приглашён"}
|
||||
</Badge>
|
||||
</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>
|
||||
{updating === member.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
member.permissions.map((p) => (
|
||||
<Badge key={p.key} variant="outline">
|
||||
{PERMISSION_LABELS[p.key]}
|
||||
</Badge>
|
||||
))
|
||||
<>
|
||||
Настроить
|
||||
</>
|
||||
)}
|
||||
{member.permissions.length === 0 && (
|
||||
<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>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -681,6 +628,17 @@ export default function SettingsPage() {
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedMember && (
|
||||
<PermissionModal
|
||||
open={showPermissionModal}
|
||||
onOpenChange={setShowPermissionModal}
|
||||
member={selectedMember}
|
||||
projects={projects}
|
||||
onSave={handlePermissionSave}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user