Files
portal/app/resources/js/api/sales.ts
T
Дмитрий 9ee4e9a79f feat(воронка продаж): корзина, дробь у «Отказа», фильтр по датам — экраны
- 12-я колонка «Корзина», результат «В корзину» просит только причину;
- в шапке «Отказа» дробь «4/2» с подсказкой «из них 2 после ручного
  тестирования»; при нуле дробь не рисуется — «69/0» это шум;
- у «Выслано КП» поле даты подписано «если договорились» и необязательно;
- орган фильтра по датам на ОБОИХ экранах, два режима + период.

Приёмка глазами пройдена живьём по всем семи пунктам, включая «Воронку отдела»
(снимки в docs/superpowers/screens/2026-08-01-korzina-filtry/priemka/).
Браузер поймал то, чего не видели тесты: при смене режима оставался прежний
период, и «что менялось за завтра» давало пустую доску — теперь период
возвращается к «Сегодня», на это заведён отдельный тест.

Прогон: сервер 1245/1245, фронт 1567/1567. Журнал схемы — v9.31.
2026-08-01 13:29:10 +03:00

1602 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import axios from 'axios';
/**
* API-клиент для портала отдела продаж (/api/sales/*).
*
* Использует Bearer-токен из salesAuth store (localStorage 'sales_token').
* НЕ использует Sanctum cookie/CSRF — это отдельный auth через токен.
*
* Base path: /api/sales
*/
export interface SalesUser {
id: number;
name: string;
email: string;
role: 'manager' | 'head';
}
export interface SalesLoginResponse {
token: string;
user: SalesUser;
}
// ─── helpers ────────────────────────────────────────────────────────────────
function getToken(): string | null {
try {
return localStorage.getItem('sales_token');
} catch {
return null;
}
}
function authHeaders(): Record<string, string> {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
/**
* Извлекает читаемое сообщение об ошибке из ответа API.
*/
export function extractSalesErrorMessage(error: unknown, fallback = 'Произошла ошибка. Попробуйте позже.'): string {
if (axios.isAxiosError(error)) {
const data = error.response?.data as { message?: string } | undefined;
if (data?.message) return data.message;
if (error.response?.status === 401) return 'Неверный email или пароль.';
if (error.response?.status === 403) return 'Нет прав на это действие.';
if (error.response?.status === 422) {
const errData = error.response.data as { errors?: Record<string, string[]> } | undefined;
const firstField = errData?.errors ? Object.values(errData.errors)[0] : undefined;
if (firstField?.[0]) return firstField[0];
}
if (error.response?.status === 500) return 'Внутренняя ошибка сервера.';
}
return fallback;
}
// ─── types ───────────────────────────────────────────────────────────────────
export interface SalesClientRow {
tenant_id: number;
organization_name: string;
inn: string | null;
subject_type: string | null;
last_activity_at: string | null; // ISO datetime or null
balance_rub: string;
status: 'trial' | 'suspended' | 'overdue' | 'active' | string;
tariff_name: string | null;
projects_count: number;
runway_days: number | null;
leads_delivered: number;
oborot_rub: number;
/** Пополнения клиента за выбранный период (§21). */
topup_rub: number;
earned_rub: number | null;
}
export interface SalesClientsParams {
period: string;
from?: string;
to?: string;
search?: string;
}
// ─── auth endpoints ──────────────────────────────────────────────────────────
/**
* POST /api/sales/auth/login → { token, user }
*/
export async function salesLogin(email: string, password: string): Promise<SalesLoginResponse> {
const { data } = await axios.post<SalesLoginResponse>(
'/api/sales/auth/login',
{ email, password },
{ headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' } },
);
return data;
}
/**
* GET /api/sales/auth/me (Bearer) → { id, name, email, role }
*/
export async function salesMe(): Promise<SalesUser> {
const { data } = await axios.get<SalesUser>('/api/sales/auth/me', {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
/**
* POST /api/sales/auth/logout (Bearer)
*/
export async function salesLogout(): Promise<void> {
await axios.post(
'/api/sales/auth/logout',
{},
{
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
},
);
}
// ─── clients endpoint ─────────────────────────────────────────────────────────
/**
* GET /api/sales/clients?period=...&from=...&to=...&search=... (Bearer)
* → { data: SalesClientRow[] }
*/
export async function listSalesClients(params: SalesClientsParams): Promise<SalesClientRow[]> {
const { data } = await axios.get<{ data: SalesClientRow[] }>('/api/sales/clients', {
params,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data.data;
}
// ─── client card types ────────────────────────────────────────────────────────
export interface SalesClientCardProfile {
organization_name: string;
contact_email: string | null;
contact_name: string | null;
contact_phone: string | null;
inn: string | null;
subject_type: string | null;
created_at: string; // ISO
desired_daily_numbers: number | null;
last_activity_at: string | null; // ISO
}
export interface SalesClientCardKpi {
balance_rub: string;
runway_days: number | null;
projects_count: number;
leads_delivered: number;
leads_target: number;
avg_lead_price_rub: number;
earned_rub: number | null;
}
export interface SalesClientCardProject {
id: number;
name: string;
signal_type: 'site' | 'call' | 'sms';
region: string[];
daily_limit_target: number;
delivered_today: number;
status: 'active' | 'paused';
}
export interface SalesClientCardLeadByDay {
date: string; // YYYY-MM-DD
count: number;
oborot_rub: number;
}
export interface SalesClientCardRecentLead {
received_at: string; // ISO
phone_masked: string;
region: string;
source: string;
project: string;
}
export interface SalesClientCardActivityItem {
created_at: string; // ISO
type: string;
amount_rub: string;
description: string;
}
export interface SalesClientCard {
profile: SalesClientCardProfile;
kpi: SalesClientCardKpi;
projects: SalesClientCardProject[];
leads_by_day: SalesClientCardLeadByDay[];
recent_leads: SalesClientCardRecentLead[];
activity: SalesClientCardActivityItem[];
}
// ─── overview types ───────────────────────────────────────────────────────────
export interface SalesOverviewTotals {
clients_count: number;
active: number;
trial: number;
overdue: number;
balance_sum_rub: string;
leads_delivered: number;
oborot_rub: number;
/** Сколько клиенты пополнили баланс за выбранный период (§21). */
topup_rub: number;
earned_rub: number | null;
}
export interface SalesOverviewAttentionRow {
tenant_id: number;
organization_name: string;
status: string;
balance_rub: string;
runway_days: number | null;
}
export interface SalesOverviewTopClient {
tenant_id: number;
organization_name: string;
leads_delivered: number;
oborot_rub: number;
}
export interface SalesOverview {
totals: SalesOverviewTotals;
attention: SalesOverviewAttentionRow[];
top_clients: SalesOverviewTopClient[];
}
export interface SalesOverviewParams {
period: string;
from?: string;
to?: string;
}
// ─── overview endpoint ────────────────────────────────────────────────────────
/**
* GET /api/sales/overview?period=...&from=...&to=... (Bearer)
* → SalesOverview
*/
export async function getSalesOverview(params: SalesOverviewParams): Promise<SalesOverview> {
const { data } = await axios.get<SalesOverview>('/api/sales/overview', {
params,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
// ─── client card endpoint ─────────────────────────────────────────────────────
/**
* GET /api/sales/clients/{tenantId}?period=...&from=...&to=... (Bearer)
* → SalesClientCard
* 403 если клиент не закреплён за менеджером.
*/
export async function getSalesClientCard(
tenantId: string | number,
params: SalesClientsParams,
): Promise<SalesClientCard> {
const { data } = await axios.get<SalesClientCard>(`/api/sales/clients/${tenantId}`, {
params,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
// ─── attachments (привязка клиентов) types ────────────────────────────────────
export type SalesAttachmentStatus = 'pending' | 'approved' | 'rejected' | 'not_found';
/**
* Строка заявки на привязку клиента к менеджеру.
* Форма зависит от роли (см. бэк-контракт GET /api/sales/attachments):
* - менеджер: базовые поля;
* - начальник (pending): + manager_name, hint;
* - начальник (history): + manager_name.
*/
export interface SalesAttachmentRow {
id: number;
login_input: string;
status: SalesAttachmentStatus;
status_label: string;
tenant_id: number | null;
organization_name: string | null;
comment: string | null;
created_at: string | null; // ISO
decided_at: string | null; // ISO
/** Только у начальника: имя менеджера-автора заявки */
manager_name?: string;
/**
* Только у начальника в очереди pending: результат проверки.
* 'свободен' | 'уже за: Имя' | null (когда клиент не найден).
*/
hint?: string | null;
}
/**
* Результат отправки заявки / решения по ней.
* POST /api/sales/attachments → 201; POST /api/sales/attachments/{id}/decide → 200.
*/
export interface SalesAttachmentSubmitResult {
id: number;
login_input: string;
status: SalesAttachmentStatus;
status_label: string;
tenant_id: number | null;
comment: string | null;
}
/**
* Ответ GET /api/sales/attachments для начальника.
*/
export interface SalesAttachmentQueue {
pending: SalesAttachmentRow[];
history: SalesAttachmentRow[];
}
// ─── attachments endpoints ────────────────────────────────────────────────────
/**
* POST /api/sales/attachments (Bearer, менеджер)
* Тело: { login }. Клиент не найден → status='not_found' (201, не ошибка).
* Уже за этим менеджером → 422.
*/
export async function submitAttachment(login: string): Promise<SalesAttachmentSubmitResult> {
const { data } = await axios.post<SalesAttachmentSubmitResult>(
'/api/sales/attachments',
{ login },
{
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
},
);
return data;
}
/**
* GET /api/sales/attachments (Bearer, менеджер) → { data: SalesAttachmentRow[] }
* Только свои заявки, newest first.
*/
export async function listMyAttachments(): Promise<SalesAttachmentRow[]> {
const { data } = await axios.get<{ data: SalesAttachmentRow[] }>('/api/sales/attachments', {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data.data;
}
/**
* GET /api/sales/attachments?scope=department (Bearer, начальник) → { pending, history }
* Очередь ВСЕГО отдела. Без scope тот же адрес отдаёт личные заявки — экраны
* раздела МЕНЕДЖЕР и НАЧАЛЬНИК строго разделены (§23).
*/
export async function listAttachmentQueue(): Promise<SalesAttachmentQueue> {
const { data } = await axios.get<SalesAttachmentQueue>('/api/sales/attachments', {
params: { scope: 'department' },
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
/**
* POST /api/sales/attachments/{id}/decide (Bearer, только начальник)
* action='approve' сам переназначает (если клиент был за другим менеджером).
* 403 если не начальник.
*/
export async function decideAttachment(
id: number,
action: 'approve' | 'reject',
comment?: string,
): Promise<SalesAttachmentSubmitResult> {
const { data } = await axios.post<SalesAttachmentSubmitResult>(
`/api/sales/attachments/${id}/decide`,
{ action, ...(comment ? { comment } : {}) },
{
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
},
);
return data;
}
// ─── tariffs (тарифы менеджеров) types ─────────────────────────────────────────
/**
* Тип (вид) тарифа менеджера (модель 03.07.2026):
* - daily_salary — суточный оклад (ставка ₽/день = base_salary_rub, per-manager);
* - topup_step — процент от пополнений с порогом + разовым бонусом и ступенями по сроку.
*/
export type SalesTariffKind = 'daily_salary' | 'topup_step';
/** Одна ступень для topup_step: срок клиента у менеджера from–to мес и ставка %. */
export interface SalesTariffStep {
from: number;
to: number;
rate: number;
}
/**
* Тариф менеджера. Форма params зависит от kind:
* - daily_salary: {} (ставка ₽/день задаётся per-manager при назначении);
* - topup_step: { threshold: number, reward: number, periods: SalesTariffStep[] }.
*/
export interface SalesTariff {
id: number;
name: string;
kind: SalesTariffKind;
params: Record<string, unknown>;
}
/** Строка менеджера в таблице назначения тарифа (для новых клиентов). */
export interface SalesTariffManager {
id: number;
name: string;
current_tariff_id: number | null;
current_tariff_name: string | null;
current_tariff_kind: SalesTariffKind | null;
base_salary_rub: number;
clients_count: number;
topups_rub: number;
oborot_rub: number;
estimated_earned_rub: number;
}
export interface SalesTariffsResponse {
tariffs: SalesTariff[];
managers: SalesTariffManager[];
}
export interface SalesTariffsParams {
period: string;
from?: string;
to?: string;
}
/** Результат POST /tariffs/assign. */
export interface SalesTariffAssignResult {
id: number;
name: string;
current_tariff_id: number | null;
base_salary_rub: number;
}
// ─── tariffs endpoints ─────────────────────────────────────────────────────────
/**
* GET /api/sales/tariffs?period=...&from=...&to=... (Bearer, начальник)
* → { tariffs, managers }
*/
export async function listSalesTariffs(params: SalesTariffsParams): Promise<SalesTariffsResponse> {
const { data } = await axios.get<SalesTariffsResponse>('/api/sales/tariffs', {
params,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
/**
* POST /api/sales/tariffs (Bearer, начальник) → 201 SalesTariff
* Валидация params по kind; ошибка → 422.
*/
export async function createSalesTariff(payload: {
name: string;
kind: SalesTariffKind;
params: Record<string, unknown>;
}): Promise<SalesTariff> {
const { data } = await axios.post<SalesTariff>('/api/sales/tariffs', payload, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
/**
* PUT /api/sales/tariffs/{id} (Bearer, начальник) → 200 SalesTariff
* kind менять нельзя; передаём только name + params.
*/
export async function updateSalesTariff(
id: number,
payload: { name: string; params: Record<string, unknown> },
): Promise<SalesTariff> {
const { data } = await axios.put<SalesTariff>(`/api/sales/tariffs/${id}`, payload, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
/**
* POST /api/sales/tariffs/assign (Bearer, начальник) → 200
* Назначает менеджеру текущий тариф (для НОВЫХ клиентов).
*/
export async function assignSalesTariff(payload: {
manager_id: number;
tariff_id: number;
base_salary_rub?: number;
}): Promise<SalesTariffAssignResult> {
const { data } = await axios.post<SalesTariffAssignResult>('/api/sales/tariffs/assign', payload, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
// ─── payouts / income (выплаты менеджерам · мой доход) types ───────────────────
/**
* Одна запись журнала выплаты менеджеру.
* Менеджер видит только свои выплаты, начальник — все.
*/
export interface SalesPayoutRow {
id: number;
sales_user_id: number;
manager_name: string | null;
amount_rub: number;
paid_on: string; // YYYY-MM-DD
comment: string | null;
created_at: string; // ISO
creator_name: string | null;
}
/**
* Строка остатка к выплате по менеджеру (только для начальника).
* remaining_rub = accrued_rub paid_all_time_rub (нарастающим итогом).
*/
export interface SalesRemainingRow {
manager_id: number;
name: string;
clients_count: number;
oborot_rub: number;
accrued_rub: number;
paid_period_rub: number;
paid_all_time_rub: number;
remaining_rub: number;
}
/** Итоги дохода менеджера за период. */
export interface SalesIncomeTotals {
oborot_rub: number;
accrued_rub: number;
paid_all_time_rub: number;
to_pay_period_rub: number;
}
/** Начисление менеджеру по одному клиенту за период. */
export interface SalesIncomePerClient {
tenant_id: number;
organization_name: string | null;
tariff_kind: string | null;
tariff_label: string;
topups_rub: number;
earned_rub: number;
}
export interface SalesIncomeResponse {
totals: SalesIncomeTotals;
per_client: SalesIncomePerClient[];
payouts: SalesPayoutRow[];
}
export interface SalesPayoutsParams {
period: string;
from?: string;
to?: string;
}
// ─── payouts / income endpoints ────────────────────────────────────────────────
/**
* GET /api/sales/payouts (Bearer)
* Менеджер видит свои выплаты, начальник — все. → { data: SalesPayoutRow[] }
*/
export async function listSalesPayouts(): Promise<SalesPayoutRow[]> {
const { data } = await axios.get<{ data: SalesPayoutRow[] }>('/api/sales/payouts', {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data.data;
}
/**
* GET /api/sales/payouts/remaining?period=... (Bearer, начальник)
* → { data: SalesRemainingRow[] }
*/
export async function getSalesPayoutsRemaining(params: SalesPayoutsParams): Promise<SalesRemainingRow[]> {
const { data } = await axios.get<{ data: SalesRemainingRow[] }>('/api/sales/payouts/remaining', {
params,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data.data;
}
/**
* POST /api/sales/payouts (Bearer, начальник) → 201 SalesPayoutRow
* Ошибка валидации → 422.
*/
export async function createSalesPayout(payload: {
manager_id: number;
amount_rub: number;
paid_on: string;
comment?: string;
}): Promise<SalesPayoutRow> {
const { data } = await axios.post<SalesPayoutRow>('/api/sales/payouts', payload, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
/**
* GET /api/sales/income?period=... (Bearer, менеджер) → SalesIncomeResponse
*/
export async function getSalesIncome(params: SalesPayoutsParams): Promise<SalesIncomeResponse> {
const { data } = await axios.get<SalesIncomeResponse>('/api/sales/income', {
params,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
// ─── invoices (счета · отметка оплаты) types ───────────────────────────────────
/** Строка счёта в таблице «Счета» портала продаж (только для начальника). */
export interface SalesInvoiceRow {
id: number;
invoice_number: string;
amount_total: string | number;
status: 'issued' | 'paid' | 'overdue' | 'cancelled';
issued_at: string; // ISO
expires_at: string | null; // ISO or null
tenant_id: number;
tenant_name: string | null;
payer_name: string | null;
}
export interface SalesInvoicesResponse {
data: SalesInvoiceRow[];
meta: {
current_page: number;
last_page: number;
total: number;
per_page: number;
};
}
export interface SalesInvoicesParams {
status?: string;
search?: string;
per_page?: number;
}
// ─── invoices endpoints ────────────────────────────────────────────────────────
/**
* GET /api/sales/invoices?status=...&search=...&per_page=... (Bearer, начальник)
* → { data: SalesInvoiceRow[], meta: {...} }
*/
export async function listSalesInvoices(params: SalesInvoicesParams): Promise<SalesInvoicesResponse> {
const { data } = await axios.get<SalesInvoicesResponse>('/api/sales/invoices', {
params,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
/**
* POST /api/sales/invoices/{id}/mark-paid (Bearer, начальник) → { status: 'ok' }
* Зачисляет баланс клиенту + формирует Акт на бэкенде.
*/
export async function markSalesInvoicePaid(id: number): Promise<{ status: string }> {
const { data } = await axios.post<{ status: string }>(
`/api/sales/invoices/${id}/mark-paid`,
{},
{
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
},
);
return data;
}
// ─── dashboard overview (сводка отдела · начальник) types ──────────────────────
/** KPI-плитки сводки отдела начальника. */
export interface SalesDashboardKpi {
managers_count: number;
managers_active: number;
managers_vacation: number;
clients_count: number;
balance_sum_rub: string;
oborot_rub: number;
/** Сколько клиенты отдела пополнили баланс за выбранный период. */
topup_rub: number;
paid_period_rub: number;
}
/** Счётчики-алерты сводки отдела (кликабельные плитки). */
export interface SalesDashboardAlerts {
invoices_awaiting: number;
attachments_pending: number;
clients_balance_problem: number;
}
/** Строка таблицы «Результативность менеджеров». */
export interface SalesDashboardPerformanceRow {
manager_id: number;
name: string;
clients_count: number;
leads_delivered: number;
oborot_rub: number;
/** Пополнения клиентов этого менеджера за период. */
topup_rub: number;
paid_all_time_rub: number;
earned_rub: number;
}
export interface SalesDashboardOverview {
kpi: SalesDashboardKpi;
alerts: SalesDashboardAlerts;
performance: SalesDashboardPerformanceRow[];
}
export interface SalesDashboardParams {
period: string;
from?: string;
to?: string;
}
// ─── dashboard overview endpoint ───────────────────────────────────────────────
/**
* GET /api/sales/dashboard/overview?period=...&from=...&to=... (Bearer, начальник)
* → SalesDashboardOverview
*/
export async function getSalesDashboardOverview(params: SalesDashboardParams): Promise<SalesDashboardOverview> {
const { data } = await axios.get<SalesDashboardOverview>('/api/sales/dashboard/overview', {
params,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
// ─── managers performance (результативность менеджеров · начальник) types ──────
/**
* Строка таблицы «Результативность менеджеров» (Task 6.2b).
* status: 'active' — работает, 'vacation' — в отпуске.
*/
export interface SalesPerformanceRow {
manager_id: number;
name: string;
email: string;
clients_count: number;
active_clients: number;
leads_delivered: number;
oborot_rub: number;
/** Пополнения клиентов этого менеджера за период. */
topup_rub: number;
paid_all_time_rub: number;
earned_rub: number;
status: 'active' | 'vacation';
}
export interface SalesPerformanceParams {
period: string;
from?: string;
to?: string;
search?: string;
}
// ─── managers performance endpoint ─────────────────────────────────────────────
/**
* GET /api/sales/managers/performance?period=...&from=...&to=...&search=... (Bearer, начальник)
* → { data: SalesPerformanceRow[] }
*/
export async function getSalesManagersPerformance(params: SalesPerformanceParams): Promise<SalesPerformanceRow[]> {
const { data } = await axios.get<{ data: SalesPerformanceRow[] }>('/api/sales/managers/performance', {
params,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data.data;
}
// ─── managers (создание менеджера + список · начальник) types ──────────────────
/**
* Строка менеджера в таблице «Менеджеры отдела» (Task 7.1b).
* is_active=false трактуется как «Отпуск».
*/
export interface SalesManagerRow {
id: number;
name: string;
email: string;
role: 'manager' | 'head';
is_active: boolean;
clients_count: number;
paid_all_time_rub: number;
}
/** Результат POST /api/sales/managers → 201. */
export interface SalesManagerCreateResult {
id: number;
name: string;
email: string;
role: 'manager' | 'head';
is_active: boolean;
}
// ─── managers endpoints ────────────────────────────────────────────────────────
/**
* GET /api/sales/managers (Bearer, начальник) → { data: SalesManagerRow[] }
*/
export async function listSalesManagers(): Promise<SalesManagerRow[]> {
const { data } = await axios.get<{ data: SalesManagerRow[] }>('/api/sales/managers', {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data.data;
}
/**
* POST /api/sales/managers (Bearer, начальник) → 201 SalesManagerCreateResult
* Дубль email / невалидные данные → 422.
*/
export async function createSalesManager(payload: {
name: string;
email: string;
password: string;
role: 'manager' | 'head';
}): Promise<SalesManagerCreateResult> {
const { data } = await axios.post<SalesManagerCreateResult>('/api/sales/managers', payload, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
// ─── Реклама на кандидатов (Яндекс.Аудитории · начальник) ─────────────────────
/**
* 11 настраиваемых сроков планировщика рекламы (Task 8) — сколько дней реклама
* крутится на каждом шаге воронки, прежде чем погаснуть сама. Ключи 1:1
* с колонками sales_ad_audience_state (см. AdAudienceScheduler).
*/
export interface SalesAdAudienceDurations {
warmup_days: number;
new_days: number;
in_work_days: number;
negotiation_overdue_days: number;
negotiation_far_threshold_days: number;
negotiation_far_head_days: number;
negotiation_far_lead_days: number;
no_answer_days: number;
rejected_days: number;
registered_days: number;
testing_days: number;
}
/**
* Строка фирмы на прогреве (Task 8) — GET /api/sales/warming/{platform}/firms.
* state/stop_reason — что сейчас с рекламой этой фирмы (см. AdAudienceScheduler);
* assigned=true → менеджер уже назначен, кнопка «Назначить менеджера» скрыта.
*/
export interface SalesAdAudienceFirmRow {
id: number;
firm_name: string;
city: string | null;
phones_count: number;
state: 'active' | 'paused' | 'stopped';
stop_reason: string | null;
resume_at: string | null;
days_in_ads: number;
prospect_id: number | null;
stage: string | null;
assigned: boolean;
ch_yandex: boolean;
ch_vk: boolean;
ch_mts: boolean;
rubric: string | null; // ниша
warmup_started_at: string | null; // дата запуска, ISO
manager_id: number | null; // назначенный менеджер
manager_name: string | null;
warming_active: boolean; // ещё греется / прогрет
warming_channels: Array<'yandex' | 'vk' | 'mts' | 'sms'>; // чем грелась
channel_status?: 'loaded' | 'warming' | null; // статус строки этого канала: загружена ждёт запуска / греется
sms_sent_count?: number; // сколько боевых СМС уже слали этой фирме (Task D2)
}
/**
* GET /api/sales/ad-audience/firms (Bearer, начальник) → { firms: SalesAdAudienceFirmRow[] }
*
* ⚠️ Исторический адрес: с разъезда на три площадки (Task 4+5, план трёх площадок)
* этот backend-маршрут в проекте больше НЕ зарегистрирован — экран «Реклама на
* кандидатов» заменён тремя SalesWarmingView (см. listWarmingFirms ниже).
* SalesSmsView.vue больше её не зовёт (Task D2 — переехал на listSmsFirms
* ниже, свой источник для канала СМС). Функция оставлена как есть — трогать
* её потребителей вне периметра этой задачи.
*/
export async function listSalesAdAudienceFirms(): Promise<SalesAdAudienceFirmRow[]> {
const { data } = await axios.get<{ firms: SalesAdAudienceFirmRow[] }>('/api/sales/ad-audience/firms', {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data.firms;
}
/**
* GET /api/sales/ad-audience/sms-firms (Bearer, начальник) → { firms: SalesAdAudienceFirmRow[] }
* Task D2 — свой источник фирм для СМС-канала: только строки firm_channels
* с channel='sms' (backend D1). Каждая строка несёт sms_sent_count — сколько
* боевых СМС этой фирме уже слали, чтобы начальник видел и не долбил человека.
*/
export async function listSmsFirms(): Promise<SalesAdAudienceFirmRow[]> {
const { data } = await axios.get<{ firms: SalesAdAudienceFirmRow[] }>('/api/sales/ad-audience/sms-firms', {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data.firms;
}
// ─── Прогрев кандидатов — три площадки (yandex | vk | mts) ────────────────────
//
// Заменяет прежний единый /api/sales/ad-audience: экран «Реклама на кандидатов»
// разъехался на три отдельных экрана SalesWarmingView.vue (Яндекс/ВК/Телеграм).
// enabled/min_phones/external_id/status — свои у каждой площадки; days и 11
// сроков планировщика (SalesAdAudienceDurations) в этом шаге пока ОБЩИЕ на все
// площадки — см. SalesAdAudienceController::platformState().
export type WarmingPlatform = 'yandex' | 'vk' | 'mts';
/** Состояние одной площадки прогрева — GET /api/sales/warming/{platform}. */
export interface WarmingPlatformState {
platform: WarmingPlatform;
enabled: boolean;
min_phones: number;
external_id: number | null;
last_synced_at: string | null;
last_error: string | null;
status: 'ok' | 'no_access' | 'waiting_volume' | 'working';
days: number;
durations: SalesAdAudienceDurations;
in_ads: number;
expiring_week: number;
waiting_sync: number;
}
/** GET /api/sales/warming/{platform} (Bearer, начальник) → { data: WarmingPlatformState } */
export async function getWarming(platform: WarmingPlatform): Promise<WarmingPlatformState> {
const { data } = await axios.get<{ data: WarmingPlatformState }>(`/api/sales/warming/${platform}`, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data.data;
}
/**
* PATCH /api/sales/warming/{platform} (Bearer, начальник) → { data: WarmingPlatformState }
* enabled — рубильник ИМЕННО этой площадки; days и 11 сроков — общие на все площадки.
* Срок вне 1..365 → 422.
*/
export async function updateWarming(
platform: WarmingPlatform,
payload: { enabled?: boolean; days?: number } & Partial<SalesAdAudienceDurations>,
): Promise<WarmingPlatformState> {
const { data } = await axios.patch<{ data: WarmingPlatformState }>(`/api/sales/warming/${platform}`, payload, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data.data;
}
/**
* GET /api/sales/warming/{platform}/firms (Bearer, начальник) → { firms: SalesAdAudienceFirmRow[] }
* Только фирмы, подписанные именно на эту площадку (ch_yandex/ch_vk/ch_mts).
*/
export async function listWarmingFirms(platform: WarmingPlatform): Promise<SalesAdAudienceFirmRow[]> {
const { data } = await axios.get<{ firms: SalesAdAudienceFirmRow[] }>(`/api/sales/warming/${platform}/firms`, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data.firms;
}
/**
* POST /api/sales/warming/{platform}/warm (Bearer, начальник) → { ok, warmed }
* «Греть» выбранные фирмы на этой площадке. Без days → воронка (funnel, сроки по
* 11 шагам площадки); с days (1..365) → простой свободный срок (flat, «грей ровно
* N дней»). Заводит/обновляет строку канала в status='warming'.
*/
export async function warmFirms(platform: WarmingPlatform, firmIds: number[], days?: number): Promise<void> {
await axios.post(
`/api/sales/warming/${platform}/warm`,
{ firm_ids: firmIds, ...(days != null ? { days } : {}) },
{
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
},
);
}
/**
* POST /api/sales/warming/{platform}/unassign (Bearer, начальник) → { ok, removed }
* «Убрать» выбранные фирмы с этой площадки — удаляет строки канала (channel=<platform>).
* Строки других каналов той же фирмы не трогаются (площадки независимы).
*/
export async function unassignFirms(platform: WarmingPlatform, firmIds: number[]): Promise<void> {
await axios.post(
`/api/sales/warming/${platform}/unassign`,
{ firm_ids: firmIds },
{
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
},
);
}
/**
* POST /api/sales/warming/{platform}/firms/{firm}/assign (Bearer, начальник) → { prospect_id }
* Из снимка фирмы рождается карточка воронки на менеджера salesUserId, стадия «Новые».
* Фирма уже отдана менеджеру → 422.
*/
export async function assignWarmingFirm(
platform: WarmingPlatform,
firmId: number,
salesUserId: number,
): Promise<{ prospect_id: number }> {
const { data } = await axios.post<{ prospect_id: number }>(
`/api/sales/warming/${platform}/firms/${firmId}/assign`,
{ sales_user_id: salesUserId },
{
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
},
);
return data;
}
/**
* POST /api/sales/ad-audience/firms/{firm}/assign (Bearer, начальник) → { prospect_id }
* Назначение менеджера без привязки к площадке — для экрана «Прогрев СМС».
* Уже назначенная фирма → 422.
*/
export async function assignAdAudienceFirm(firmId: number, salesUserId: number): Promise<{ prospect_id: number }> {
const { data } = await axios.post<{ prospect_id: number }>(
`/api/sales/ad-audience/firms/${firmId}/assign`,
{ sales_user_id: salesUserId },
{ headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest', ...authHeaders() } },
);
return data;
}
/**
* GET /api/sales/warming/mts/file (Bearer, начальник) — файл со списком
* номеров для ручной загрузки в МТС Маркетолог (программного доступа к рекламе
* в Telegram у МТС нет — их REST API умеет только SMS).
*
* Маршрут за auth:sales (Bearer-токен, который SPA шлёт через axios) — обычная
* <a href> его не пошлёт и получит 401. Качаем через тот же axios-слой, что
* и остальные запросы, и отдаём файл пользователю через Blob + временную ссылку.
*/
export async function downloadWarmingMtsFile(): Promise<void> {
const response = await axios.get('/api/sales/warming/mts/file', {
responseType: 'blob',
headers: {
Accept: 'text/plain',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
const disposition = response.headers['content-disposition'] as string | undefined;
const match = disposition?.match(/filename="?([^"]+)"?/);
const filename = match?.[1] ?? `liderra-mts-${new Date().toISOString().slice(0, 10)}.txt`;
const blobUrl = URL.createObjectURL(response.data as Blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(blobUrl);
}
// ─── Потенциальные клиенты (воронка-канбан) ───────────────────────────────────
/**
* Данные фирмы из поиска (Python sales-finder → карточка воронки).
* Ключи 1:1 с dataclass Firm (salesfinder/models.py). Все поля опциональны:
* на демо-карточках заполнена лишь часть, полный набор приходит из Этапа 2.
*/
export interface ProspectPayload {
// юрлицо (ДаДата / ЕГРЮЛ)
legal_name?: string | null;
ogrn?: string | null;
address?: string | null;
legal_status?: string | null;
// руководитель (ПДн — из ЕГРЮЛ/ЕГРИП по личному ИНН)
director?: string | null;
director_post?: string | null;
director_is_ip?: boolean;
director_inn?: string | null;
contact_phone?: string | null;
contact_email?: string | null;
contact_status?: string | null;
// реклама / Директ (Keys.so)
direct_budget_min?: number;
direct_budget?: number; // верх вилки, ₽/мес
direct_keys?: number;
direct_ads?: number;
channels?: string[];
advertises?: string | null;
confidence?: string | null;
hotness?: number;
card_url?: string | null;
two_gis_promoted?: boolean;
[key: string]: unknown;
}
/** Происхождение карточки: отдал начальник из поиска / менеджер завёл сам. */
export type ProspectSource = 'search' | 'manager';
/** Контактное лицо кандидата: у одного человека может быть несколько телефонов (§16.1). */
export interface ProspectContact {
name: string;
position: string | null;
phones: string[];
}
/** Куда менеджер выслал тестовые лиды вручную. */
export type ProspectTestChannel = 'phone' | 'site';
/** Каким путём ушло коммерческое предложение. */
export type ProspectKpChannel = 'email' | 'whatsapp' | 'telegram' | 'max' | 'other';
export interface SalesProspect {
id: number;
sales_user_id: number;
stage: string;
/** Откуда карточка приехала на текущую стадию — из него считается дробь «69/1». */
prev_stage: string | null;
source: ProspectSource;
/** Бренд/вывеска — его показываем на карточке канбана. */
firm_name: string;
/** Юрлицо (подтягивается по ИНН из ДаData, правится руками). */
legal_name: string | null;
contacts: ProspectContact[];
city: string | null;
phone: string | null;
site: string | null;
inn: string | null;
rating_label: string | null;
payload: ProspectPayload;
next_call_at: string | null;
reason: string | null;
registered_email: string | null;
/** «Ручное тестирование»: куда слали тест и что именно указано. */
test_channel: ProspectTestChannel | null;
test_target: string | null;
/** «Выслано КП»: каким путём ушло и по какому адресу/номеру. */
kp_channel: ProspectKpChannel | null;
kp_target: string | null;
notes: string | null;
/** Состояние прогрева фирмы этой карточки — значки витрины (кусок B). */
warming?: WarmingBadgeState;
}
/** Код канала прогрева для значков витрины. */
export type WarmingChannelCode = 'yandex' | 'vk' | 'mts' | 'sms';
/**
* Значки витрины прогрева (кусок B), считаны из летописи эпизодов:
* active — идёт ли прогрев хоть на одном канале; по каждому каналу с историей —
* live (открытый эпизод = «идёт сейчас», иначе «грели раньше») и count («×N»).
*/
export interface WarmingBadgeState {
active: boolean;
channels: Partial<Record<WarmingChannelCode, { live: boolean; count: number }>>;
}
export interface ProspectsResponse {
prospects: SalesProspect[];
by_stage: Record<string, SalesProspect[]>;
stages: string[];
/** Только для начальника: число карточек по каждому менеджеру (sales_user_id → count). */
manager_counts?: Record<string, number>;
}
/**
* Фильтр доски по датам:
* todo — что надо сделать в этот срок (плюс всё просроченное);
* changed — что менялось в этот срок (любое движение карточки).
*/
export type ProspectDateMode = 'todo' | 'changed';
/** Вид периода — те же, что понимает SalesPeriodResolver на сервере. */
export type ProspectPeriodKind = 'today' | 'tomorrow' | 'yesterday' | 'd7' | 'd30' | 'custom';
export interface ProspectDateFilterValue {
mode: ProspectDateMode | null;
period: ProspectPeriodKind;
from?: string | null;
to?: string | null;
}
export async function listProspects(
managerId?: number,
source?: ProspectSource,
scope?: 'department',
dateFilter?: ProspectDateFilterValue | null,
): Promise<ProspectsResponse> {
const params: Record<string, unknown> = {};
if (scope) params.scope = scope;
if (managerId) params.manager_id = managerId;
if (source) params.source = source;
if (dateFilter?.mode) {
params.date_mode = dateFilter.mode;
params.period = dateFilter.period;
// Произвольный период уходит только парой дат — сервер на половинчатом
// ответит 422, и доска дёрнулась бы ошибкой на полпути выбора.
if (dateFilter.period === 'custom' && dateFilter.from && dateFilter.to) {
params.from = dateFilter.from;
params.to = dateFilter.to;
}
}
const { data } = await axios.get<ProspectsResponse>('/api/sales/prospects', {
headers: authHeaders(),
params,
});
return data;
}
export interface ProspectResultPayload {
/**
* opened — карточка открыта менеджером (первое открытие из «Новые» → «Взят в работу»);
* back_to_new — вернуть из «Взят в работу» обратно в «Новые».
*/
action:
| 'negotiation'
| 'no_answer'
| 'rejected'
| 'registered'
| 'manual_testing'
| 'kp_sent'
| 'trash'
| 'opened'
| 'back_to_new';
next_call_at?: string;
reason?: string;
email?: string;
test_channel?: ProspectTestChannel;
test_target?: string;
kp_channel?: ProspectKpChannel;
kp_target?: string;
/** Краткое содержание разговора — ложится отдельной записью в журнал (§20). */
summary?: string;
}
/** Поля при заведении менеджером своего кандидата (инициатива). */
export interface ProspectCreatePayload {
firm_name: string;
/** ИНН по желанию: обязательно только название (§16.1, правка 28.07.2026). */
inn?: string | null;
legal_name?: string | null;
contacts?: ProspectContact[];
city?: string | null;
phone?: string | null;
site?: string | null;
notes?: string | null;
}
/** Ответ подсказки по ИНН (§16.4). Ничего не сохраняет — только подставляет в форму. */
export interface ProspectInnLookup {
found: boolean;
legal_name?: string | null;
address?: string | null;
city?: string | null;
}
/** POST /api/sales/prospects/lookup-inn — юрлицо и город по ИНН через ДаData. */
export async function lookupProspectInn(inn: string): Promise<ProspectInnLookup> {
const { data } = await axios.post<ProspectInnLookup>(
'/api/sales/prospects/lookup-inn',
{ inn },
{ headers: authHeaders() },
);
return data;
}
/**
* POST /api/sales/prospects — менеджер заводит своего кандидата.
* Карточка всегда создаётся автору и помечается source='manager'.
*/
export async function createProspect(body: ProspectCreatePayload): Promise<SalesProspect> {
const { data } = await axios.post<{ prospect: SalesProspect }>('/api/sales/prospects', body, {
headers: authHeaders(),
});
return data.prospect;
}
/** Запись журнала разговоров: 'note' — написал менеджер, 'stage' — автозапись о смене стадии. */
export interface ProspectNote {
id: number;
kind: 'note' | 'stage';
/** Что решили («Договорились на созвон 20.07.2026 17:35»); пусто у ручных заметок. */
title: string | null;
body: string;
author: string | null;
created_at: string;
}
/** GET /api/sales/prospects/{id}/notes — журнал разговоров, свежие сверху. */
export async function listProspectNotes(id: number): Promise<ProspectNote[]> {
const { data } = await axios.get<{ notes: ProspectNote[] }>(`/api/sales/prospects/${id}/notes`, {
headers: authHeaders(),
});
return data.notes;
}
/** POST /api/sales/prospects/{id}/notes — краткое содержание разговора (только добавление). */
export async function addProspectNote(id: number, body: string): Promise<ProspectNote> {
const { data } = await axios.post<{ note: ProspectNote }>(
`/api/sales/prospects/${id}/notes`,
{ body },
{ headers: authHeaders() },
);
return data.note;
}
/**
* PATCH /api/sales/prospects/{id}/contacts — менеджер фиксирует контактных лиц
* после обзвона предполагаемых номеров. Список заменяется целиком.
*/
export async function updateProspectContacts(id: number, contacts: ProspectContact[]): Promise<SalesProspect> {
const { data } = await axios.patch<{ prospect: SalesProspect }>(
`/api/sales/prospects/${id}/contacts`,
{ contacts },
{ headers: authHeaders() },
);
return data.prospect;
}
export async function updateProspect(id: number, body: ProspectResultPayload): Promise<SalesProspect> {
const { data } = await axios.patch<{ prospect: SalesProspect }>(`/api/sales/prospects/${id}`, body, {
headers: authHeaders(),
});
return data.prospect;
}
// ─── Прогрев СМС ──────────────────────────────────────────────────────────────
//
// Экран «Прогрев СМС» ходит в сервер только через эти обёртки — как и соседние
// разделы портала продаж. Прямых вызовов axios из компонента быть не должно.
export interface SalesSmsSenderRow {
id: number;
name: string;
provider_key: string;
status: string;
rejected_reason: string | null;
}
export interface SalesSmsCampaignRow {
id: number;
title: string;
body: string;
status: string;
planned_count: number;
sent_count: number;
failed_count: number;
skipped_count: number;
estimated_cost_kopecks: number;
actual_cost_kopecks: number;
created_at: string | null;
}
export interface SalesSmsMessageRow {
id: number;
phone: string;
operator: string | null;
provider_key: string | null;
status: string;
segments: number;
cost_kopecks: number;
error: string | null;
}
export interface SalesSmsCampaignsResponse {
campaigns: SalesSmsCampaignRow[];
sandbox: boolean;
senders: SalesSmsSenderRow[];
}
export interface SalesSmsPreview {
segments: number;
sendable_count: number;
/** { номер: причина пропуска } — причины приходят кодами skipped_*. */
skipped: Record<string, string>;
estimated_cost_kopecks: number;
}
/** Номер из прогрева, привязанный к отмеченной фирме. */
export interface SalesSmsRecipientRow {
phone: string;
firm_name: string | null;
city: string | null;
operator: string | null;
phone_type: string | null;
}
/** GET /api/sales/sms/campaigns (Bearer, начальник) — список рассылок, песочница, отправители. */
export async function fetchSmsCampaigns(): Promise<SalesSmsCampaignsResponse> {
const { data } = await axios.get<SalesSmsCampaignsResponse>('/api/sales/sms/campaigns', {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data;
}
/** GET /api/sales/sms/campaigns/{id} (Bearer, начальник) — карточка с журналом по номерам. */
export async function fetchSmsCampaign(id: number): Promise<SalesSmsCampaignRow & { messages?: SalesSmsMessageRow[] }> {
const { data } = await axios.get<{
campaign: SalesSmsCampaignRow & { messages?: SalesSmsMessageRow[] };
}>(`/api/sales/sms/campaigns/${id}`, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data.campaign;
}
/** POST /api/sales/sms/preview (Bearer, начальник) — цена и причины пропуска ДО отправки. */
export async function previewSms(body: string, phones: string[]): Promise<SalesSmsPreview> {
const { data } = await axios.post<SalesSmsPreview>(
'/api/sales/sms/preview',
{ body, phones },
{
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
},
);
return data;
}
/** POST /api/sales/sms/campaigns (Bearer, начальник) — запуск рассылки. */
export async function createSmsCampaign(payload: {
title: string;
body: string;
phones: string[];
sender_id: number | null;
}): Promise<SalesSmsCampaignRow> {
const { data } = await axios.post<{ campaign: SalesSmsCampaignRow }>('/api/sales/sms/campaigns', payload, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
});
return data.campaign;
}
/**
* POST /api/sales/sms/recipients (Bearer, начальник) — номера отмеченных фирм.
* Начальник отмечает фирмы галочками, номера подтягиваются сами.
*/
export async function fetchSmsRecipients(firmIds: number[]): Promise<SalesSmsRecipientRow[]> {
const { data } = await axios.post<{ phones: SalesSmsRecipientRow[] }>(
'/api/sales/sms/recipients',
{ firm_ids: firmIds },
{
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...authHeaders(),
},
},
);
return data.phones;
}