feat: update purchase plans
This commit is contained in:
@@ -7,6 +7,7 @@ import type {
|
||||
Workspace,
|
||||
WorkspaceCreateRequest,
|
||||
WorkspaceUpdateRequest,
|
||||
Project,
|
||||
PaginatedResponse,
|
||||
PaginationParams,
|
||||
} from "@/lib/types/api";
|
||||
@@ -52,5 +53,11 @@ export const workspacesApi = {
|
||||
*/
|
||||
deleteAvatar: (workspaceId: string) =>
|
||||
api.delete<Workspace>(`${BASE_PATH}/${workspaceId}/avatar`),
|
||||
|
||||
/**
|
||||
* Get projects for workspace
|
||||
*/
|
||||
getProjects: (workspaceId: string, params?: PaginationParams) =>
|
||||
api.get<PaginatedResponse<Project>>(`${BASE_PATH}/${workspaceId}/projects`, { params }),
|
||||
};
|
||||
|
||||
|
||||
@@ -21,51 +21,51 @@ export const COLUMN_GROUPS = [
|
||||
] as const;
|
||||
|
||||
export const ALL_COLUMNS: ColumnConfig[] = [
|
||||
// Basic
|
||||
{ id: "checkbox", label: "Чекбокс", group: "basic", editable: false, required: true },
|
||||
{ id: "number", label: "Номер", group: "basic", editable: false, required: true },
|
||||
{ id: "status_deal", label: "Статус сделки", group: "basic", editable: true, partialEdit: true },
|
||||
{ id: "status_post", label: "Статус поста", group: "basic", editable: true, partialEdit: true },
|
||||
// Basic - узкие колонки
|
||||
{ id: "checkbox", label: "", group: "basic", editable: false, required: true, width: "32px" },
|
||||
{ id: "number", label: "№", group: "basic", editable: false, required: true, width: "40px" },
|
||||
{ id: "status_deal", label: "Статус сделки", group: "basic", editable: true, partialEdit: true, width: "minmax(120px, 1.2fr)" },
|
||||
{ id: "status_post", label: "Статус поста", group: "basic", editable: true, partialEdit: true, width: "minmax(120px, 1.2fr)" },
|
||||
|
||||
// Content
|
||||
{ id: "creative", label: "Креатив", group: "content", editable: true },
|
||||
{ id: "invite_link", label: "Пригл. ссылка", group: "content", editable: false },
|
||||
{ id: "post_link", label: "Ссылка на пост", group: "content", editable: false },
|
||||
{ id: "creative", label: "Креатив", group: "content", editable: true, width: "minmax(140px, 1.5fr)" },
|
||||
{ id: "invite_link", label: "Ссылка", group: "content", editable: false, width: "minmax(80px, 0.8fr)" },
|
||||
{ id: "post_link", label: "Пост", group: "content", editable: false, width: "minmax(50px, 0.5fr)" },
|
||||
|
||||
// Finance
|
||||
{ id: "cost", label: "Стоимость", group: "finance", editable: true, partialEdit: true },
|
||||
{ id: "cost_before", label: "Стоимость до торга", group: "finance", editable: true },
|
||||
{ id: "cost_format", label: "Формат оплаты", group: "finance", editable: true, partialEdit: true },
|
||||
{ id: "placement_type", label: "Тип закупа", group: "finance", editable: true },
|
||||
{ id: "discount", label: "% скидки", group: "finance", editable: false },
|
||||
{ id: "payment_date", label: "Дата оплаты", group: "finance", editable: true },
|
||||
{ id: "cost", label: "Стоимость", group: "finance", editable: true, partialEdit: true, width: "minmax(80px, 0.8fr)" },
|
||||
{ id: "cost_before", label: "До торга", group: "finance", editable: true, width: "minmax(80px, 0.8fr)" },
|
||||
{ id: "cost_format", label: "Форм. оплаты", group: "finance", editable: true, partialEdit: true, width: "minmax(80px, 0.8fr)" },
|
||||
{ id: "placement_type", label: "Тип закупа", group: "finance", editable: true, width: "minmax(100px, 1fr)" },
|
||||
{ id: "discount", label: "Скидка", group: "finance", editable: false, width: "minmax(50px, 0.5fr)" },
|
||||
{ id: "payment_date", label: "Оплата", group: "finance", editable: true, width: "minmax(90px, 0.8fr)" },
|
||||
|
||||
// Dates
|
||||
{ id: "planned_date", label: "Плановая дата", group: "dates", editable: true },
|
||||
{ id: "actual_date", label: "Фактическая дата", group: "dates", editable: false },
|
||||
{ id: "format", label: "Плановый формат", group: "dates", editable: true },
|
||||
{ id: "time_top", label: "Время в топе", group: "dates", editable: false },
|
||||
{ id: "time_in_feed", label: "Время в ленте", group: "dates", editable: false },
|
||||
{ id: "link_created", label: "Дата ссылки", group: "dates", editable: false },
|
||||
{ id: "post_deleted", label: "Дата удаления", group: "dates", editable: false },
|
||||
{ id: "planned_date", label: "План. выход", group: "dates", editable: true, width: "minmax(90px, 0.8fr)" },
|
||||
{ id: "actual_date", label: "Факт", group: "dates", editable: false, width: "minmax(90px, 0.8fr)" },
|
||||
{ id: "format", label: "Формат", group: "dates", editable: true, width: "minmax(100px, 0.8fr)" },
|
||||
{ id: "time_top", label: "В топе", group: "dates", editable: false, width: "minmax(60px, 0.5fr)" },
|
||||
{ id: "time_in_feed", label: "В ленте", group: "dates", editable: false, width: "minmax(60px, 0.5fr)" },
|
||||
{ id: "link_created", label: "Ссылка", group: "dates", editable: false, width: "minmax(80px, 0.7fr)" },
|
||||
{ id: "post_deleted", label: "Удалён", group: "dates", editable: false, width: "minmax(80px, 0.7fr)" },
|
||||
|
||||
// Metrics
|
||||
{ id: "subscriptions", label: "Всего подписок", group: "metrics", editable: false },
|
||||
{ id: "views", label: "Просмотры", group: "metrics", editable: false },
|
||||
{ id: "subscriptions", label: "Подписки", group: "metrics", editable: false, width: "minmax(70px, 0.6fr)" },
|
||||
{ id: "views", label: "Просмотры", group: "metrics", editable: false, width: "minmax(70px, 0.6fr)" },
|
||||
|
||||
// Calculated
|
||||
{ id: "cpf", label: "CPF", group: "calculated", editable: false },
|
||||
{ id: "cpm", label: "CPM", group: "calculated", editable: true, partialEdit: true },
|
||||
{ id: "conversion_24h", label: "Конверсия 24ч", group: "calculated", editable: false },
|
||||
{ id: "conversion_48h", label: "Конверсия 48ч", group: "calculated", editable: false },
|
||||
{ id: "conversion_total", label: "Конверсия общ.", group: "calculated", editable: false },
|
||||
{ id: "total_unsubs", label: "Всего отписок", group: "calculated", editable: false },
|
||||
{ id: "unsub_percent", label: "% отписок", group: "calculated", editable: false },
|
||||
{ id: "total_active", label: "Итого подписок", group: "calculated", editable: false },
|
||||
{ id: "cpf", label: "CPF", group: "calculated", editable: false, width: "minmax(50px, 0.5fr)" },
|
||||
{ id: "cpm", label: "CPM", group: "calculated", editable: true, partialEdit: true, width: "minmax(50px, 0.5fr)" },
|
||||
{ id: "conversion_24h", label: "Конв. 24ч", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
|
||||
{ id: "conversion_48h", label: "Конв. 48ч", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
|
||||
{ id: "conversion_total", label: "Конв. общ.", group: "calculated", editable: false, width: "minmax(70px, 0.6fr)" },
|
||||
{ id: "total_unsubs", label: "Отписки", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
|
||||
{ id: "unsub_percent", label: "% отписок", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
|
||||
{ id: "total_active", label: "Итого", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" },
|
||||
|
||||
// Technical
|
||||
{ id: "invite_type", label: "Тип ссылки", group: "technical", editable: true },
|
||||
{ id: "comment", label: "Комментарий", group: "technical", editable: true },
|
||||
{ id: "invite_type", label: "Тип ссылки", group: "technical", editable: true, width: "minmax(100px, 1fr)" },
|
||||
{ id: "comment", label: "Комментарий", group: "technical", editable: true, width: "minmax(150px, 2fr)" },
|
||||
] as const;
|
||||
|
||||
// Default visible columns
|
||||
@@ -81,9 +81,9 @@ export const DEFAULT_VISIBLE_COLUMNS = new Set([
|
||||
"cost_format",
|
||||
"planned_date",
|
||||
"format",
|
||||
"subscriptions",
|
||||
"views",
|
||||
"cpf",
|
||||
// "subscriptions",
|
||||
// "views",
|
||||
// "cpf",
|
||||
"cpm",
|
||||
"comment",
|
||||
]);
|
||||
|
||||
205
lib/types/permission.ts
Normal file
205
lib/types/permission.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { PermissionKey, Permission, PermissionScope, PermissionScopeType } from "./api";
|
||||
|
||||
export const PERMISSION_GROUPS = {
|
||||
admin: ["admin_full"] as const,
|
||||
projects: ["projects_read", "projects_write"] as const,
|
||||
creatives: ["creatives_read", "creatives_write"] as const,
|
||||
placements: ["placements_read", "placements_write"] as const,
|
||||
analytics: ["analytics_read", "analytics_without_clicks", "analytics_own_creatives"] as const,
|
||||
} as const;
|
||||
|
||||
export type PermissionGroup = keyof typeof PERMISSION_GROUPS;
|
||||
|
||||
export const PERMISSION_ICONS: 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 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 function hasGlobalPermission(permissions: Permission[], key: PermissionKey): boolean {
|
||||
return permissions.some((p) => p.key === key && (!p.scopes || p.scopes.length === 0));
|
||||
}
|
||||
|
||||
export function hasScopedPermission(
|
||||
permissions: Permission[],
|
||||
key: PermissionKey,
|
||||
scopeType: PermissionScopeType,
|
||||
scopeId: string
|
||||
): boolean {
|
||||
const perm = permissions.find((p) => p.key === key);
|
||||
if (!perm || !perm.scopes) return false;
|
||||
return perm.scopes.some((s) => s.type === scopeType && s.id === scopeId);
|
||||
}
|
||||
|
||||
export function getScopesForPermission(
|
||||
permissions: Permission[],
|
||||
key: PermissionKey,
|
||||
scopeType: PermissionScopeType
|
||||
): string[] {
|
||||
const perm = permissions.find((p) => p.key === key);
|
||||
if (!perm || !perm.scopes) return [];
|
||||
return perm.scopes.filter((s) => s.type === scopeType).map((s) => s.id);
|
||||
}
|
||||
|
||||
export function addGlobalPermission(permissions: Permission[], key: PermissionKey): Permission[] {
|
||||
const perm = permissions.find((p) => p.key === key);
|
||||
if (perm) {
|
||||
if (!perm.scopes || perm.scopes.length === 0) {
|
||||
return permissions;
|
||||
}
|
||||
const newPerms = permissions.filter((p) => p.key !== key);
|
||||
return [...newPerms, { key, scopes: undefined }];
|
||||
}
|
||||
return [...permissions, { key, scopes: undefined }];
|
||||
}
|
||||
|
||||
export function removePermission(permissions: Permission[], key: PermissionKey): Permission[] {
|
||||
return permissions.filter((p) => p.key !== key);
|
||||
}
|
||||
|
||||
export function setScopedPermission(
|
||||
permissions: Permission[],
|
||||
key: PermissionKey,
|
||||
scopeType: PermissionScopeType,
|
||||
scopeIds: string[]
|
||||
): Permission[] {
|
||||
const existing = permissions.find((p) => p.key === key);
|
||||
const otherPerms = permissions.filter((p) => p.key !== key);
|
||||
|
||||
if (scopeIds.length === 0) {
|
||||
return otherPerms;
|
||||
}
|
||||
|
||||
const existingScopes = existing?.scopes?.filter((s) => s.type !== scopeType) || [];
|
||||
const newScopes = [...existingScopes, ...scopeIds.map((id) => ({ type: scopeType, id }))];
|
||||
|
||||
return [...otherPerms, { key, scopes: newScopes }];
|
||||
}
|
||||
|
||||
export function toggleScopedPermission(
|
||||
permissions: Permission[],
|
||||
key: PermissionKey,
|
||||
scopeType: PermissionScopeType,
|
||||
scopeId: string
|
||||
): Permission[] {
|
||||
const existing = permissions.find((p) => p.key === key);
|
||||
const otherPerms = permissions.filter((p) => p.key !== key);
|
||||
|
||||
if (!existing) {
|
||||
return [...permissions, { key, scopes: [{ type: scopeType, id: scopeId }] }];
|
||||
}
|
||||
|
||||
const scopes = existing.scopes || [];
|
||||
const hasScope = scopes.some((s) => s.type === scopeType && s.id === scopeId);
|
||||
|
||||
if (hasScope) {
|
||||
const filtered = scopes.filter((s) => !(s.type === scopeType && s.id === scopeId));
|
||||
if (filtered.length === 0) {
|
||||
return otherPerms;
|
||||
}
|
||||
return [...otherPerms, { key, scopes: filtered }];
|
||||
}
|
||||
|
||||
return [...otherPerms, { key, scopes: [...scopes, { type: scopeType, id: scopeId }] }];
|
||||
}
|
||||
|
||||
export function mergePermissions(
|
||||
current: Permission[],
|
||||
updates: Permission[]
|
||||
): Permission[] {
|
||||
const result = new Map<string, Permission>();
|
||||
|
||||
for (const perm of current) {
|
||||
result.set(perm.key, perm);
|
||||
}
|
||||
|
||||
for (const update of updates) {
|
||||
const existing = result.get(update.key);
|
||||
if (!existing) {
|
||||
result.set(update.key, update);
|
||||
} else if (!update.scopes || update.scopes.length === 0) {
|
||||
result.set(update.key, update);
|
||||
} else {
|
||||
const existingScopes = existing.scopes || [];
|
||||
const newScopes = new Map<string, PermissionScope>();
|
||||
|
||||
for (const scope of existingScopes) {
|
||||
newScopes.set(`${scope.type}:${scope.id}`, scope);
|
||||
}
|
||||
for (const scope of update.scopes) {
|
||||
newScopes.set(`${scope.type}:${scope.id}`, scope);
|
||||
}
|
||||
result.set(update.key, { key: update.key, scopes: Array.from(newScopes.values()) });
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(result.values());
|
||||
}
|
||||
|
||||
export function calculatePermissionDiff(
|
||||
original: Permission[],
|
||||
updated: Permission[]
|
||||
): { added: Permission[]; removed: Permission[]; modified: Permission[] } {
|
||||
const added: Permission[] = [];
|
||||
const removed: Permission[] = [];
|
||||
const modified: Permission[] = [];
|
||||
|
||||
const originalKeys = new Set(original.map((p) => p.key));
|
||||
const updatedKeys = new Set(updated.map((p) => p.key));
|
||||
|
||||
for (const perm of updated) {
|
||||
if (!originalKeys.has(perm.key)) {
|
||||
added.push(perm);
|
||||
}
|
||||
}
|
||||
|
||||
for (const perm of original) {
|
||||
if (!updatedKeys.has(perm.key)) {
|
||||
removed.push(perm);
|
||||
}
|
||||
}
|
||||
|
||||
for (const perm of updated) {
|
||||
const orig = original.find((p) => p.key === perm.key);
|
||||
if (orig && JSON.stringify(orig.scopes) !== JSON.stringify(perm.scopes)) {
|
||||
modified.push(perm);
|
||||
}
|
||||
}
|
||||
|
||||
return { added, removed, modified };
|
||||
}
|
||||
|
||||
export function isPermissionFullyGlobal(permissions: Permission[], key: PermissionKey): boolean {
|
||||
const perm = permissions.find((p) => p.key === key);
|
||||
return perm !== undefined && (!perm.scopes || perm.scopes.length === 0);
|
||||
}
|
||||
|
||||
export function getAllScopeIdsForPermission(
|
||||
permissions: Permission[],
|
||||
key: PermissionKey,
|
||||
scopeType: PermissionScopeType
|
||||
): string[] {
|
||||
const perm = permissions.find((p) => p.key === key);
|
||||
if (!perm || !perm.scopes) return [];
|
||||
return perm.scopes.filter((s) => s.type === scopeType).map((s) => s.id);
|
||||
}
|
||||
Reference in New Issue
Block a user