feat: purchase-plans global update

This commit is contained in:
ivannoskov
2026-02-02 21:01:11 +03:00
parent f9c334ba22
commit 735077e707
11 changed files with 2575 additions and 534 deletions

View File

@@ -12,20 +12,9 @@ import type {
ViewsHistoryItem,
ViewsHistoryQueryParams,
PaginatedResponse,
UpdatePlacementInProjectRequest,
} from "@/lib/types/api";
type UpdatePlacementInProjectRequest = Partial<{
status: string;
comment: string | null;
creative_id: string | null;
placement_at: string | null;
payment_at: string | null;
cost: { type: "fixed"; value: number } | null;
cost_before_bargain: number | null;
placement_type: string | null;
format: string | null;
}>;
export const placementsApi = {
/**
* Get list of placements for workspace
@@ -40,15 +29,22 @@ export const placementsApi = {
* Get placements for a specific project (grouped by channel UI)
*/
getByProject: (workspaceId: string, projectId: string) =>
api.get<{ placements: Placement[] }>(`/workspaces/${workspaceId}/projects/${projectId}/placements`)
.then((response) => response.placements),
api.get<PaginatedResponse<Placement>>(`/workspaces/${workspaceId}/projects/${projectId}/placements`)
.then((response) => response.items),
/**
* Get single placement
* Get single placement (deprecated - use getInProject for project-specific data)
*/
get: (workspaceId: string, placementId: string) =>
api.get<Placement>(`/workspaces/${workspaceId}/placements/${placementId}`),
/**
* Get single placement within project
* GET /workspaces/{workspace_id}/projects/{project_id}/placements/{placement_id}
*/
getInProject: (workspaceId: string, projectId: string, placementId: string) =>
api.get<PlacementWithStats>(`/workspaces/${workspaceId}/projects/${projectId}/placements/${placementId}`),
/**
* Create new placement
*/
@@ -124,10 +120,13 @@ export const placementsApi = {
),
/**
* Delete (archive) placement
* Delete placement within project
* DELETE /workspaces/{workspace_id}/projects/{project_id}/placements/{placement_id}
*/
delete: (workspaceId: string, placementId: string) =>
api.delete<void>(`/workspaces/${workspaceId}/placements/${placementId}`),
deleteInProject: (workspaceId: string, projectId: string, placementId: string) =>
api.delete<void>(
`/workspaces/${workspaceId}/projects/${projectId}/placements/${placementId}`
),
/**
* Get views history for placement

View File

@@ -0,0 +1,148 @@
import type { PlacementStatus, PlacementPostStatus, CostType, PlacementType, InviteLinkType } from "@/lib/types/api";
export interface ColumnConfig {
id: string;
label: string;
group: string;
editable: boolean;
width?: string;
required?: boolean;
}
export const COLUMN_GROUPS = [
{ id: "basic", label: "Основная информация" },
{ id: "content", label: "Контент" },
{ id: "finance", label: "Финансы" },
{ id: "dates", label: "Даты и время" },
{ id: "metrics", label: "Метрики" },
{ id: "calculated", label: "Расчетные показатели" },
{ id: "technical", label: "Технические детали" },
] 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 },
{ id: "status_post", label: "Статус поста", group: "basic", editable: true },
// Content
{ id: "creative", label: "Креатив", group: "content", editable: true },
{ id: "invite_link", label: "Пригласительная ссылка", group: "content", editable: false },
{ id: "post_link", label: "Ссылка на пост", group: "content", editable: false },
// Finance
{ id: "cost", label: "Стоимость", group: "finance", editable: true },
{ id: "cost_before", label: "Стоимость до торга", group: "finance", editable: true },
{ id: "cost_format", label: "Формат оплаты", group: "finance", editable: true },
{ id: "placement_type", label: "Тип закупа", group: "finance", editable: true },
{ id: "discount", label: "% скидки", group: "finance", editable: false },
{ id: "payment_date", label: "Дата оплаты", group: "finance", editable: true },
// 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 },
// Metrics
{ id: "subscriptions", label: "Всего подписок", group: "metrics", editable: false },
{ id: "views", label: "Просмотры", group: "metrics", editable: false },
// Calculated
{ id: "cpf", label: "CPF", group: "calculated", editable: false },
{ id: "cpm", label: "CPM", group: "calculated", editable: false },
{ 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 },
// Technical
{ id: "invite_type", label: "Тип ссылки", group: "technical", editable: true },
{ id: "comment", label: "Комментарий", group: "technical", editable: true },
] as const;
// Default visible columns
export const DEFAULT_VISIBLE_COLUMNS = new Set([
"checkbox",
"number",
"status_deal",
"status_post",
"creative",
"invite_link",
"cost",
"cost_before",
"cost_format",
"planned_date",
"format",
"subscriptions",
"views",
"cpf",
"cpm",
"comment",
]);
// Status lists
export const PLACEMENT_DEAL_STATUSES: PlacementStatus[] = [
"Без статуса",
"Написать",
"Ждём ответа",
"Согласование условий",
"Оплатить",
"Оплачено",
"Отмена",
"Не подходит цена",
"Неактуально",
"Не отвечает",
];
export const PLACEMENT_POST_STATUSES: PlacementPostStatus[] = [
"Без статуса",
"Отправить пост",
"Согласование поста",
"Ожидание отложки",
"Запланирован",
"Пост вышел",
"Размещение отработало - пост удалён",
"Размещение отработало - пост не удалён",
"Проверить - пост удалён раньше срока",
"Проверить - пост не вышел",
"Размещение отработало",
];
export const COST_FORMATS: CostType[] = ["fixed", "cpm"];
export const PLACEMENT_TYPES: PlacementType[] = ["self_promo", "standard"];
export const INVITE_TYPES: InviteLinkType[] = ["public", "approval"];
// Manual post statuses (user can select before post is published)
export const MANUAL_POST_STATUSES: Set<PlacementPostStatus> = new Set([
"Без статуса",
"Отправить пост",
"Согласование поста",
"Ожидание отложки",
"Запланирован",
]);
// Automatic post statuses (set by backend)
export const AUTOMATIC_POST_STATUSES: Set<PlacementPostStatus> = new Set([
"Пост вышел",
"Размещение отработало - пост удалён",
"Размещение отработало - пост не удалён",
"Проверить - пост удалён раньше срока",
"Проверить - пост не вышел",
"Размещение отработало",
]);
// Helper functions
export const canEditPostStatus = (currentStatus: PlacementPostStatus): boolean => {
return MANUAL_POST_STATUSES.has(currentStatus);
};
export const isAutomaticPostStatus = (status: PlacementPostStatus): boolean => {
return AUTOMATIC_POST_STATUSES.has(status);
};

View File

@@ -312,15 +312,19 @@ export const demoPlacements: (Placement & { project_id: string })[] = [
project_id: "proj-0001-0000-0000-000000000001",
status: "Оплачено",
creative_id: "crea-0001-0000-0000-000000000001",
creative_name: "Креатив 1",
comment: "crypto_jan",
invite_link: "https://t.me/+abc123",
invite_link_created_at: "2024-12-19T10:00:00Z",
invite_link_type: "public",
channel: demoPlacementChannels[0],
project: demoProjects[0],
short_id: "abc123",
details: {
placement_at: "2024-12-20T12:00:00Z",
payment_at: "2024-12-19T10:00:00Z",
cost: { type: "fixed", value: 5500 },
cost_before_bargain: 6000,
cost_before_bargain: { type: "fixed", value: 6000 },
placement_type: "self_promo",
format: "Стандарт",
comment: null,
@@ -329,6 +333,8 @@ export const demoPlacements: (Placement & { project_id: string })[] = [
placement_post: {
subscriptions_count: 156,
views_count: 8500,
status: "Пост вышел",
time_on_top: null,
created_at: "2024-12-20T12:00:00Z",
post: {
id: "post-0001-0000-0000-000000000001",
@@ -336,6 +342,7 @@ export const demoPlacements: (Placement & { project_id: string })[] = [
text: "Привет! Отличные новости...",
url: "https://t.me/tech_future/12345",
deleted_from_channel_at: null,
published_at: null,
created_at: "2024-12-20T12:00:00Z",
updated_at: "2024-12-20T12:00:00Z",
},
@@ -347,15 +354,19 @@ export const demoPlacements: (Placement & { project_id: string })[] = [
project_id: "proj-0001-0000-0000-000000000001",
status: "Согласование условий",
creative_id: "crea-0001-0000-0000-000000000001",
creative_name: "Креатив 1",
comment: "crypto_feb",
invite_link: "https://t.me/+def456",
invite_link_created_at: null,
invite_link_type: "approval",
channel: demoPlacementChannels[1],
project: demoProjects[0],
short_id: "def456",
details: {
placement_at: "2024-12-25T14:00:00Z",
payment_at: null,
cost: { type: "fixed", value: 3200 },
cost_before_bargain: 3500,
cost_before_bargain: { type: "fixed", value: 3500 },
placement_type: "self_promo",
format: "Стандарт",
comment: null,
@@ -369,15 +380,19 @@ export const demoPlacements: (Placement & { project_id: string })[] = [
project_id: "proj-0001-0000-0000-000000000001",
status: "Оплачено",
creative_id: "crea-0001-0000-0000-000000000001",
creative_name: "Креатив 1",
comment: null,
invite_link: "https://t.me/+ghi789",
invite_link_created_at: "2024-12-14T16:00:00Z",
invite_link_type: "public",
channel: demoPlacementChannels[2],
project: demoProjects[0],
short_id: "ghi789",
details: {
placement_at: "2024-12-15T10:00:00Z",
payment_at: "2024-12-14T16:00:00Z",
cost: { type: "fixed", value: 4800 },
cost_before_bargain: 5000,
cost_before_bargain: { type: "fixed", value: 5000 },
placement_type: "self_promo",
format: "Стандарт",
comment: null,
@@ -386,6 +401,8 @@ export const demoPlacements: (Placement & { project_id: string })[] = [
placement_post: {
subscriptions_count: 234,
views_count: 12000,
status: "Пост вышел",
time_on_top: null,
created_at: "2024-12-15T10:00:00Z",
post: {
id: "post-0002-0000-0000-000000000002",
@@ -393,6 +410,7 @@ export const demoPlacements: (Placement & { project_id: string })[] = [
text: "Криптовалюты продолжают расти...",
url: "https://t.me/programmers/67890",
deleted_from_channel_at: null,
published_at: null,
created_at: "2024-12-15T10:00:00Z",
updated_at: "2024-12-15T10:00:00Z",
},
@@ -404,66 +422,172 @@ export const demoPlacements: (Placement & { project_id: string })[] = [
project_id: "proj-0001-0000-0000-000000000001",
status: "Ждём ответа",
creative_id: "crea-0001-0000-0000-000000000001",
creative_name: "Креатив 1",
comment: null,
invite_link: "https://t.me/+jkl012",
invite_link_type: "public",
invite_link: null,
invite_link_created_at: null,
invite_link_type: "approval",
channel: demoPlacementChannels[3],
project: demoProjects[0],
short_id: "jkl012",
details: {
placement_at: "2024-12-28T09:00:00Z",
placement_at: "2024-12-30T11:00:00Z",
payment_at: null,
cost: { type: "fixed", value: 2500 },
cost_before_bargain: 2800,
cost: { type: "fixed", value: 6100 },
cost_before_bargain: { type: "fixed", value: 6500 },
placement_type: "self_promo",
format: "Стандарт",
comment: null,
creative_id: "crea-0001-0000-0000-000000000001",
},
placement_post: null,
created_at: "2024-12-24T14:00:00Z",
created_at: "2024-12-27T08:00:00Z",
},
{
id: "plac-0005-0000-0000-000000000005",
project_id: "proj-0002-0000-0000-000000000002",
status: "Написать",
creative_id: "crea-0002-0000-0000-000000000002",
comment: "Новый оффер",
project_id: "proj-0001-0000-0000-000000000001",
status: "Неактуально",
creative_id: "crea-0001-0000-0000-000000000001",
creative_name: "Креатив 1",
comment: "Разобрались без рекламы",
invite_link: null,
invite_link_type: "approval",
channel: demoPlacementChannels[0],
invite_link_created_at: null,
invite_link_type: "public",
channel: demoPlacementChannels[4],
project: demoProjects[0],
short_id: "mno345",
details: {
placement_at: null,
payment_at: null,
cost: { type: "fixed", value: 6000 },
cost_before_bargain: null,
cost: { type: "fixed", value: 7300 },
cost_before_bargain: { type: "fixed", value: 7800 },
placement_type: "self_promo",
format: "Спонсорский",
format: "Стандарт",
comment: null,
creative_id: "crea-0002-0000-0000-000000000002",
creative_id: "crea-0001-0000-0000-000000000001",
},
placement_post: null,
created_at: "2024-12-26T08:00:00Z",
created_at: "2024-12-25T14:00:00Z",
},
{
id: "plac-0006-0000-0000-000000000006",
project_id: "proj-0002-0000-0000-000000000002",
status: "Отмена",
creative_id: "crea-0001-0000-0000-000000000001",
comment: "Неактуально",
status: "Согласование условий",
creative_id: "crea-0002-0000-0000-000000000002",
creative_name: "Креатив 2",
comment: null,
invite_link: null,
invite_link_type: "public",
channel: demoPlacementChannels[1],
invite_link_created_at: null,
invite_link_type: "approval",
channel: demoPlacementChannels[5],
project: demoProjects[1],
short_id: "pqr678",
details: {
placement_at: null,
placement_at: "2025-01-05T15:00:00Z",
payment_at: null,
cost: null,
cost_before_bargain: null,
placement_type: null,
format: null,
comment: "Клиент отказался",
creative_id: null,
cost: { type: "fixed", value: 4100 },
cost_before_bargain: { type: "fixed", value: 4500 },
placement_type: "self_promo",
format: "Стандарт",
comment: null,
creative_id: "crea-0002-0000-0000-000000000002",
},
placement_post: null,
created_at: "2024-12-10T10:00:00Z",
created_at: "2025-01-02T10:00:00Z",
},
{
id: "plac-0007-0000-0000-000000000007",
project_id: "proj-0002-0000-0000-000000000002",
status: "Оплачено",
creative_id: "crea-0002-0000-0000-000000000002",
creative_name: "Креатив 2",
comment: "finance_mar",
invite_link: "https://t.me/+stu901",
invite_link_created_at: "2025-01-06T12:00:00Z",
invite_link_type: "public",
channel: demoPlacementChannels[6],
project: demoProjects[1],
short_id: "stu901",
details: {
placement_at: "2025-01-07T09:00:00Z",
payment_at: "2025-01-06T12:00:00Z",
cost: { type: "fixed", value: 5700 },
cost_before_bargain: { type: "fixed", value: 6100 },
placement_type: "self_promo",
format: "Стандарт",
comment: null,
creative_id: "crea-0002-0000-0000-000000000002",
},
placement_post: {
subscriptions_count: 298,
views_count: 15500,
status: "Пост вышел",
time_on_top: null,
created_at: "2025-01-07T09:00:00Z",
post: {
id: "post-0003-0000-0000-000000000003",
message_id: 11111,
text: "Важное обновление в нашем продукте...",
url: "https://t.me/product_updates/11111",
deleted_from_channel_at: null,
published_at: null,
created_at: "2025-01-07T09:00:00Z",
updated_at: "2025-01-07T09:00:00Z",
},
},
created_at: "2025-01-04T11:00:00Z",
},
{
id: "plac-0008-0000-0000-000000000008",
project_id: "proj-0002-0000-0000-000000000002",
status: "Ждём ответа",
creative_id: "crea-0002-0000-0000-000000000002",
creative_name: "Креатив 2",
comment: null,
invite_link: null,
invite_link_created_at: null,
invite_link_type: "approval",
channel: demoPlacementChannels[7],
project: demoProjects[1],
short_id: "vwx234",
details: {
placement_at: "2025-01-12T16:00:00Z",
payment_at: null,
cost: { type: "fixed", value: 6900 },
cost_before_bargain: { type: "fixed", value: 7400 },
placement_type: "self_promo",
format: "Стандарт",
comment: null,
creative_id: "crea-0002-0000-0000-000000000002",
},
placement_post: null,
created_at: "2025-01-09T13:00:00Z",
},
{
id: "plac-0009-0000-0000-000000000009",
project_id: "proj-0003-0000-0000-000000000003",
status: "Согласование условий",
creative_id: "crea-0003-0000-0000-000000000003",
creative_name: "Креатив 3",
comment: null,
invite_link: null,
invite_link_created_at: null,
invite_link_type: "approval",
channel: demoPlacementChannels[8],
project: demoProjects[2],
short_id: "yza567",
details: {
placement_at: "2025-01-18T11:00:00Z",
payment_at: null,
cost: { type: "fixed", value: 3500 },
cost_before_bargain: { type: "fixed", value: 3800 },
placement_type: "self_promo",
format: "Стандарт",
comment: null,
creative_id: "crea-0003-0000-0000-000000000003",
},
placement_post: null,
created_at: "2025-01-15T09:00:00Z",
},
];

View File

@@ -245,12 +245,27 @@ export type PlacementStatus =
| "Неактуально"
| "Не отвечает";
export type InviteLinkType = "public" | "approval";
export type PostViewsAvailability =
| "unknown"
| "available"
| "unavailable"
| "manual";
export type PlacementPostStatus =
| "Без статуса"
| "Отправить пост"
| "Согласование поста"
| "Ожидание отложки"
| "Запланирован"
| "Пост вышел"
| "Размещение отработало - пост удалён"
| "Размещение отработало - пост не удалён"
| "Проверить - пост удалён раньше срока"
| "Проверить - пост не вышел"
| "Размещение отработало";
export type InviteLinkType = "public" | "approval";
export type PlacementType = "self_promo" | "standard";
export type CostType = "fixed" | "cpm";
export type PostViewsAvailability =
| "unknown"
| "available"
| "unavailable"
| "manual";
export interface PlacementChannel {
id: string;
@@ -261,7 +276,7 @@ export interface PlacementChannel {
}
export interface PlacementCostInfo {
type: "fixed" | "cpm";
type: CostType;
value: number;
}
@@ -269,8 +284,8 @@ export interface PlacementDetails {
placement_at: string | null; // ISO 8601
payment_at: string | null; // ISO 8601
cost: PlacementCostInfo | null;
cost_before_bargain: number | null;
placement_type: string | null;
cost_before_bargain: PlacementCostInfo | null;
placement_type: PlacementType | null;
format: string | null;
comment: string | null;
creative_id: string | null;
@@ -286,27 +301,44 @@ export interface PlacementPost {
text: string;
url: string | null;
deleted_from_channel_at: string | null;
published_at: string | null;
created_at: string;
updated_at: string;
} | null;
}
export interface PlacementPostWithStatus extends PlacementPost {
status: PlacementPostStatus;
time_on_top: number | null;
}
export interface Placement {
id: string;
status: PlacementStatus;
creative_id: string | null;
creative_name: string | null;
comment: string | null;
invite_link: string | null;
invite_link_created_at: string | null;
invite_link_type: InviteLinkType;
channel: PlacementChannel;
project: Project | null;
short_id: string;
details: PlacementDetails | null;
placement_post: PlacementPost | null;
placement_post: PlacementPostWithStatus | null;
created_at: string;
}
export interface PlacementWithStats extends Placement {
cpf: number | null;
cpm: number | null;
time_in_feed?: number | null;
conversion_24h?: number | null;
conversion_48h?: number | null;
conversion_total?: number | null;
total_unsubs?: number | null;
unsub_percent?: number | null;
total_active?: number | null;
}
export interface PlacementsGroupedByChannel {
@@ -345,6 +377,19 @@ export interface PlacementUpdateRequest {
ad_post_url?: string;
}
export interface UpdatePlacementInProjectRequest {
status?: string;
comment?: string | null;
creative_id?: string | null;
creative_name?: string | null;
placement_at?: string | null;
payment_at?: string | null;
cost?: { type: CostType; value: number } | null;
cost_before_bargain?: { type: CostType; value: number } | null;
placement_type?: PlacementType | null;
format?: string | null;
}
export interface PlacementsQueryParams extends PaginationParams {
project_id?: string;
placement_channel_id?: string;