9ee4e9a79f
- 12-я колонка «Корзина», результат «В корзину» просит только причину; - в шапке «Отказа» дробь «4/2» с подсказкой «из них 2 после ручного тестирования»; при нуле дробь не рисуется — «69/0» это шум; - у «Выслано КП» поле даты подписано «если договорились» и необязательно; - орган фильтра по датам на ОБОИХ экранах, два режима + период. Приёмка глазами пройдена живьём по всем семи пунктам, включая «Воронку отдела» (снимки в docs/superpowers/screens/2026-08-01-korzina-filtry/priemka/). Браузер поймал то, чего не видели тесты: при смене режима оставался прежний период, и «что менялось за завтра» давало пустую доску — теперь период возвращается к «Сегодня», на это заведён отдельный тест. Прогон: сервер 1245/1245, фронт 1567/1567. Журнал схемы — v9.31.
1602 lines
58 KiB
TypeScript
1602 lines
58 KiB
TypeScript
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;
|
||
}
|