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

@@ -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>
</>
);