feat: settings page update
This commit is contained in:
@@ -1699,7 +1699,7 @@ export default function PurchasePlanDetailPage() {
|
|||||||
onClick={() => setFiltersOpen(true)}
|
onClick={() => setFiltersOpen(true)}
|
||||||
>
|
>
|
||||||
<Filter className="h-4 w-4 mr-1" />
|
<Filter className="h-4 w-4 mr-1" />
|
||||||
Фильтры
|
Фильтры и сортировка
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<span className="ml-1 px-1.5 py-0.5 text-xs bg-primary-foreground/20 rounded">
|
<span className="ml-1 px-1.5 py-0.5 text-xs bg-primary-foreground/20 rounded">
|
||||||
✓
|
✓
|
||||||
|
|||||||
@@ -59,28 +59,7 @@ import {
|
|||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import type { WorkspaceMember, WorkspaceInvite, PermissionKey } from "@/lib/types/api";
|
import type { WorkspaceMember, WorkspaceInvite, PermissionKey } from "@/lib/types/api";
|
||||||
|
import { PERMISSION_LABELS, ALL_PERMISSIONS } from "@/lib/types/api";
|
||||||
// ============================================================================
|
|
||||||
// Constants
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const PERMISSION_LABELS: Record<PermissionKey, string> = {
|
|
||||||
admin_full: "Полный доступ",
|
|
||||||
projects_read: "Просмотр проектов",
|
|
||||||
projects_write: "Редактирование проектов",
|
|
||||||
placements_read: "Просмотр размещений",
|
|
||||||
placements_write: "Редактирование размещений",
|
|
||||||
analytics_read: "Просмотр аналитики",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ALL_PERMISSIONS: PermissionKey[] = [
|
|
||||||
"admin_full",
|
|
||||||
"projects_read",
|
|
||||||
"projects_write",
|
|
||||||
"placements_read",
|
|
||||||
"placements_write",
|
|
||||||
"analytics_read",
|
|
||||||
];
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Component
|
// Component
|
||||||
|
|||||||
@@ -226,10 +226,10 @@ export function FiltersModal({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Filter className="h-5 w-5" />
|
<Filter className="h-5 w-5" />
|
||||||
Фильтры
|
Фильтры и сортировка
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Настройте фильтры для каналов и размещений
|
Настройте фильтры и сортировку для каналов и размещений
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -254,7 +254,6 @@ export function FiltersModal({
|
|||||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ArrowUpDown className="h-4 w-4" />
|
|
||||||
Размещения
|
Размещения
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -553,84 +552,87 @@ export function FiltersModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 pt-2 border-t">
|
<div className="pt-4 mt-4 border-t">
|
||||||
<Label className="text-sm font-medium flex items-center gap-2">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<ArrowUpDown className="h-4 w-4" />
|
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
|
||||||
Сортировка внутри каналов
|
<h3 className="text-sm font-semibold">Сортировка</h3>
|
||||||
</Label>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="space-y-3">
|
||||||
<Select
|
<Label className="text-xs text-muted-foreground">Сортировать размещения внутри каналов</Label>
|
||||||
value={filters.sort?.field || ""}
|
<div className="flex gap-2 items-center">
|
||||||
onValueChange={(field) =>
|
<Select
|
||||||
setFilters({
|
value={filters.sort?.field || ""}
|
||||||
...filters,
|
onValueChange={(field) =>
|
||||||
sort: filters.sort
|
setFilters({
|
||||||
? { ...filters.sort, field }
|
...filters,
|
||||||
: { field, direction: "asc" },
|
sort: filters.sort
|
||||||
})
|
? { ...filters.sort, field }
|
||||||
}
|
: { field, direction: "asc" },
|
||||||
>
|
})
|
||||||
<SelectTrigger className="h-8 text-sm w-48">
|
|
||||||
<SelectValue placeholder="Без сортировки" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{PLACEMENT_SORT_FIELDS.map((field) => (
|
|
||||||
<SelectItem key={field.key} value={field.key}>
|
|
||||||
{field.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{filters.sort && (
|
|
||||||
<div className="flex gap-1 p-0.5 border rounded-md">
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setFilters({
|
|
||||||
...filters,
|
|
||||||
sort: { ...filters.sort!, direction: "asc" },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-center px-3 py-1 rounded-sm transition-colors text-xs",
|
|
||||||
filters.sort.direction === "asc"
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "hover:bg-muted"
|
|
||||||
)}
|
|
||||||
title="По возрастанию"
|
|
||||||
>
|
|
||||||
<ArrowUp className="h-3 w-3 mr-1" />
|
|
||||||
А-Я
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setFilters({
|
|
||||||
...filters,
|
|
||||||
sort: { ...filters.sort!, direction: "desc" },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-center px-3 py-1 rounded-sm transition-colors text-xs",
|
|
||||||
filters.sort.direction === "desc"
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "hover:bg-muted"
|
|
||||||
)}
|
|
||||||
title="По убыванию"
|
|
||||||
>
|
|
||||||
<ArrowDown className="h-3 w-3 mr-1" />
|
|
||||||
Я-А
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{filters.sort && (
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setFilters({ ...filters, sort: null })
|
|
||||||
}
|
}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground underline"
|
|
||||||
>
|
>
|
||||||
Сбросить
|
<SelectTrigger className="h-8 text-sm w-48">
|
||||||
</button>
|
<SelectValue placeholder="Без сортировки" />
|
||||||
)}
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PLACEMENT_SORT_FIELDS.map((field) => (
|
||||||
|
<SelectItem key={field.key} value={field.key}>
|
||||||
|
{field.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{filters.sort && (
|
||||||
|
<div className="flex gap-1 p-0.5 border rounded-md">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
sort: { ...filters.sort!, direction: "asc" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center px-3 py-1 rounded-sm transition-colors text-xs",
|
||||||
|
filters.sort.direction === "asc"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
title="По возрастанию"
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-3 w-3 mr-1" />
|
||||||
|
А-Я
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
sort: { ...filters.sort!, direction: "desc" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center px-3 py-1 rounded-sm transition-colors text-xs",
|
||||||
|
filters.sort.direction === "desc"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
title="По убыванию"
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-3 w-3 mr-1" />
|
||||||
|
Я-А
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filters.sort && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({ ...filters, sort: null })
|
||||||
|
}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground underline"
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ interface NavItem {
|
|||||||
url: string;
|
url: string;
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
requiredPermission?: "projects_read" | "placements_read" | "analytics_read" | "admin_full";
|
requiredPermission?: "admin_full" | "projects_read" | "projects_write" | "creatives_read" | "creatives_write" | "placements_read" | "placements_write" | "analytics_read" | "analytics_without_clicks" | "analytics_own_creatives";
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
items?: {
|
items?: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -404,8 +404,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Аналитика — только подразделы
|
// 5. Аналитика — проверяем любой из аналитических доступов
|
||||||
if (hasPermission("analytics_read")) {
|
if (hasPermission("analytics_read") || hasPermission("analytics_without_clicks") || hasPermission("analytics_own_creatives")) {
|
||||||
items.push({
|
items.push({
|
||||||
title: "Аналитика",
|
title: "Аналитика",
|
||||||
url: buildRoute.analytics(workspaceId),
|
url: buildRoute.analytics(workspaceId),
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ interface WorkspaceContextType {
|
|||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
hasPermission: (key: PermissionKey, projectId?: string) => boolean;
|
hasPermission: (key: PermissionKey, projectId?: string, creativeId?: string, placementId?: string) => boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
// Check if user is admin in a specific workspace
|
// Check if user is admin in a specific workspace
|
||||||
isWorkspaceAdmin: (workspaceId: string) => Promise<boolean>;
|
isWorkspaceAdmin: (workspaceId: string) => Promise<boolean>;
|
||||||
@@ -141,7 +141,7 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
|
|||||||
const isAdmin = permissions.some((p) => p.key === "admin_full");
|
const isAdmin = permissions.some((p) => p.key === "admin_full");
|
||||||
|
|
||||||
const hasPermission = useCallback(
|
const hasPermission = useCallback(
|
||||||
(key: PermissionKey, projectId?: string): boolean => {
|
(key: PermissionKey, projectId?: string, creativeId?: string, placementId?: string): boolean => {
|
||||||
// Admin has all permissions
|
// Admin has all permissions
|
||||||
if (isAdmin) return true;
|
if (isAdmin) return true;
|
||||||
|
|
||||||
@@ -154,13 +154,30 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
|
|||||||
|
|
||||||
// If projectId provided, check if it's in scopes
|
// If projectId provided, check if it's in scopes
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
return permission.scopes.some(
|
const hasProjectScope = permission.scopes.some(
|
||||||
(s) => s.type === "project" && s.id === projectId
|
(s) => s.type === "project" && s.id === projectId
|
||||||
);
|
);
|
||||||
|
if (hasProjectScope) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permission exists but is scoped - need projectId to verify
|
// If creativeId provided, check if it's in scopes
|
||||||
return true;
|
if (creativeId) {
|
||||||
|
const hasCreativeScope = permission.scopes.some(
|
||||||
|
(s) => s.type === "creative" && s.id === creativeId
|
||||||
|
);
|
||||||
|
if (hasCreativeScope) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If placementId provided, check if it's in scopes
|
||||||
|
if (placementId) {
|
||||||
|
const hasPlacementScope = permission.scopes.some(
|
||||||
|
(s) => s.type === "placement" && s.id === placementId
|
||||||
|
);
|
||||||
|
if (hasPlacementScope) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission exists but is scoped and no matching scope found
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
[permissions, isAdmin]
|
[permissions, isAdmin]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -64,11 +64,15 @@ export type PermissionKey =
|
|||||||
| "admin_full"
|
| "admin_full"
|
||||||
| "projects_read"
|
| "projects_read"
|
||||||
| "projects_write"
|
| "projects_write"
|
||||||
|
| "creatives_read"
|
||||||
|
| "creatives_write"
|
||||||
| "placements_read"
|
| "placements_read"
|
||||||
| "placements_write"
|
| "placements_write"
|
||||||
| "analytics_read";
|
| "analytics_read"
|
||||||
|
| "analytics_without_clicks"
|
||||||
|
| "analytics_own_creatives";
|
||||||
|
|
||||||
export type PermissionScopeType = "project";
|
export type PermissionScopeType = "project" | "creative" | "placement";
|
||||||
|
|
||||||
export interface PermissionScope {
|
export interface PermissionScope {
|
||||||
type: PermissionScopeType;
|
type: PermissionScopeType;
|
||||||
@@ -80,6 +84,32 @@ export interface Permission {
|
|||||||
scopes?: PermissionScope[];
|
scopes?: PermissionScope[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 const ALL_PERMISSIONS: PermissionKey[] = [
|
||||||
|
"admin_full",
|
||||||
|
"projects_read",
|
||||||
|
"projects_write",
|
||||||
|
"creatives_read",
|
||||||
|
"creatives_write",
|
||||||
|
"placements_read",
|
||||||
|
"placements_write",
|
||||||
|
"analytics_read",
|
||||||
|
"analytics_without_clicks",
|
||||||
|
"analytics_own_creatives",
|
||||||
|
];
|
||||||
|
|
||||||
export interface WorkspaceMemberUser {
|
export interface WorkspaceMemberUser {
|
||||||
id: string; // user_id
|
id: string; // user_id
|
||||||
telegram_id: number;
|
telegram_id: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user