feat: settings page update

This commit is contained in:
ivannoskov
2026-02-02 21:29:06 +03:00
parent 735077e707
commit 233bdf7907
6 changed files with 140 additions and 112 deletions

View File

@@ -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">

View File

@@ -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

View File

@@ -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>

View File

@@ -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),

View File

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

View File

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