daa41bcecf
Решение заказчика 03.07.2026: два вида тарифа вместо трёх — суточный оклад daily_salary и процент от пополнений topup_step. Убраны percent_oborot и fix_per_client. Миграция 2026_07_03_120000 меняет CHECK sales_tariffs.kind, освобождает FK sales_users.current_tariff_id и sales_client_assignments.tariff_id, удаляет тарифы уходящих видов. SalesEarningsService для уходящих видов возвращает 0 по default-ветке. Снимки привязок не трогаются. Обновлены контроллеры тарифов и дохода, сервисы Earnings и Metrics, фронт SalesTariffsView и api/sales.ts, демо-сид, тесты бэка и фронта, CHANGELOG схемы v8.61 и спека портала. Не на проде: ветка feat/sales-portal-demo. Проверка: Sales Feature 169/169, Vitest SalesTariffs 10/10, Larastan 0. Co-Authored-By: Claude Opus 4.8 1M context <noreply@anthropic.com>
914 lines
30 KiB
TypeScript
914 lines
30 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;
|
||
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;
|
||
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 (Bearer, начальник) → { pending, history }
|
||
*/
|
||
export async function listAttachmentQueue(): Promise<SalesAttachmentQueue> {
|
||
const { data } = await axios.get<SalesAttachmentQueue>('/api/sales/attachments', {
|
||
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;
|
||
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;
|
||
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;
|
||
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;
|
||
}
|