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

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

View File

@@ -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
View 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);
}