1130 lines
40 KiB
TypeScript
1130 lines
40 KiB
TypeScript
import { apiClient, ensureCsrfCookie } from './client';
|
||
|
||
/**
|
||
* API-вызовы для админских endpoint'ов SaaS (см. ImpersonationController).
|
||
*
|
||
* На MVP вызываются без auth:saas-admin middleware (см. routes/web.php).
|
||
* Production: middleware('auth:saas-admin') + cookie session — apiClient уже
|
||
* настроен на withCredentials.
|
||
*/
|
||
|
||
export interface ImpersonationInitPayload {
|
||
tenant_id: number;
|
||
requested_by: number; // на MVP параметром; на prod — request()->user()->id
|
||
reason: string; // ≥30 chars (валидируется на backend)
|
||
}
|
||
|
||
export interface ImpersonationInitResponse {
|
||
token_id: number;
|
||
expires_at: string; // ISO8601
|
||
sent_to_email: string;
|
||
/** dev-only: исчезнет после интеграции MailService на prod */
|
||
_dev_plain_code?: string;
|
||
}
|
||
|
||
export async function impersonationInit(payload: ImpersonationInitPayload): Promise<ImpersonationInitResponse> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<ImpersonationInitResponse>('/api/admin/impersonation/init', payload);
|
||
return data;
|
||
}
|
||
|
||
export interface ImpersonationVerifyPayload {
|
||
token_id: number;
|
||
code: string; // 6 цифр
|
||
}
|
||
|
||
export interface ImpersonationVerifyResponse {
|
||
token_id: number;
|
||
tenant_id: number;
|
||
used_at: string;
|
||
message: string;
|
||
machine_token: string;
|
||
expires_at: string;
|
||
}
|
||
|
||
export async function impersonationVerify(payload: ImpersonationVerifyPayload): Promise<ImpersonationVerifyResponse> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<ImpersonationVerifyResponse>('/api/admin/impersonation/verify', payload);
|
||
return data;
|
||
}
|
||
|
||
export interface ImpersonationEndResponse {
|
||
token_id: number;
|
||
session_ended_at: string;
|
||
message: string;
|
||
}
|
||
|
||
export async function impersonationEnd(tokenId: number): Promise<ImpersonationEndResponse> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<ImpersonationEndResponse>('/api/admin/impersonation/end', {
|
||
token_id: tokenId,
|
||
});
|
||
return data;
|
||
}
|
||
|
||
export interface ImpersonationActiveSession {
|
||
token_id: number;
|
||
tenant_id: number;
|
||
tenant_name: string | null;
|
||
requested_by: number;
|
||
reason: string;
|
||
sent_to_email: string;
|
||
used_at: string;
|
||
expires_at: string;
|
||
}
|
||
|
||
export interface ImpersonationRecentSession {
|
||
token_id: number;
|
||
tenant_id: number;
|
||
tenant_name: string | null;
|
||
requested_by: number;
|
||
reason: string;
|
||
used_at: string;
|
||
session_ended_at: string;
|
||
duration_seconds: number | null;
|
||
}
|
||
|
||
export async function impersonationActive(): Promise<ImpersonationActiveSession[]> {
|
||
const { data } = await apiClient.get<{ sessions: ImpersonationActiveSession[] }>('/api/admin/impersonation/active');
|
||
return data.sessions;
|
||
}
|
||
|
||
export async function impersonationRecent(): Promise<ImpersonationRecentSession[]> {
|
||
const { data } = await apiClient.get<{ sessions: ImpersonationRecentSession[] }>('/api/admin/impersonation/recent');
|
||
return data.sessions;
|
||
}
|
||
|
||
// === SaaS-admin → Тенанты: lookup для AdminTenantsView ===
|
||
|
||
export interface AdminTenant {
|
||
id: number;
|
||
subdomain: string;
|
||
organization_name: string;
|
||
contact_email: string;
|
||
status: string;
|
||
balance_rub: string;
|
||
balance_leads: number;
|
||
is_trial: boolean;
|
||
/** Последний вход из журнала auth_log за 90 дней; null — входов не было. */
|
||
last_activity_at: string | null;
|
||
/** Состояние по порогам 3/14 суток — для цветной точки в списке. */
|
||
aliveness_state: ApiAlivenessState;
|
||
tariff_id: number | null;
|
||
tariff_name: string | null;
|
||
/** price_monthly активного тарифа если не-trial; иначе null. */
|
||
mrr_rub: string | null;
|
||
desired_daily_numbers: number | null;
|
||
chargeback_unrecovered_rub: string;
|
||
created_at: string | null;
|
||
}
|
||
|
||
interface AdminTenantsStats {
|
||
total: number;
|
||
active: number;
|
||
trial: number;
|
||
overdue: number;
|
||
}
|
||
|
||
export interface ListAdminTenantsParams {
|
||
status?: string;
|
||
/** Производные статусы UI (trial/overdue/active/suspended), csv — серверный multi-фильтр. */
|
||
statuses?: string;
|
||
/** Имена тарифов (tariff_plans.name), csv — серверный multi-фильтр. */
|
||
tariffs?: string;
|
||
search?: string;
|
||
limit?: number;
|
||
offset?: number;
|
||
}
|
||
|
||
export interface ListAdminTenantsResponse {
|
||
tenants: AdminTenant[];
|
||
total: number;
|
||
limit: number;
|
||
offset: number;
|
||
stats: AdminTenantsStats;
|
||
}
|
||
|
||
export async function listAdminTenants(params: ListAdminTenantsParams = {}): Promise<ListAdminTenantsResponse> {
|
||
const { data } = await apiClient.get<ListAdminTenantsResponse>('/api/admin/tenants', { params });
|
||
return data;
|
||
}
|
||
|
||
// === SaaS-admin → Тенанты → детали (для AdminTenantDetailView) ===
|
||
|
||
export interface ApiTenantUser {
|
||
id: number;
|
||
email: string;
|
||
first_name: string | null;
|
||
last_name: string | null;
|
||
is_active: boolean;
|
||
totp_enabled: boolean;
|
||
last_active_at: string | null;
|
||
last_login_at: string | null;
|
||
}
|
||
|
||
export interface ApiTenantProject {
|
||
id: number;
|
||
name: string;
|
||
tag: string | null;
|
||
is_active: boolean;
|
||
daily_limit_target: number;
|
||
suppliers_count: number;
|
||
leads_today: number;
|
||
}
|
||
|
||
export interface ApiTenantBalanceTx {
|
||
id: number;
|
||
type: string;
|
||
amount_rub: string;
|
||
amount_leads: number;
|
||
balance_rub_after: string | null;
|
||
description: string | null;
|
||
created_at: string;
|
||
}
|
||
|
||
export type ApiAlivenessState = 'active' | 'cooling' | 'lost' | 'never_logged_in' | 'no_data';
|
||
|
||
export interface ApiTenantAliveness {
|
||
state: ApiAlivenessState;
|
||
last_login_at: string | null;
|
||
days_since_login: number | null;
|
||
last_login_ip: string | null;
|
||
last_login_user_agent: string | null;
|
||
days: Array<{ date: string; visits: number }>;
|
||
counters: {
|
||
visits_30d: number;
|
||
active_days_30d: number;
|
||
actions_30d: number;
|
||
failed_logins_30d: number;
|
||
};
|
||
}
|
||
|
||
export interface ApiTenantActivityItem {
|
||
id: string;
|
||
at: string;
|
||
kind: 'project' | 'deal' | 'money';
|
||
event: string;
|
||
actor: string | null;
|
||
subject: string | null;
|
||
amount_rub: string | null;
|
||
bulk_count: number;
|
||
}
|
||
|
||
export interface ApiTenantActivityPage {
|
||
items: ApiTenantActivityItem[];
|
||
offset: number;
|
||
limit: number;
|
||
has_more: boolean;
|
||
}
|
||
|
||
interface ApiTenantMetrics {
|
||
leads_today: number;
|
||
leads_this_week: number;
|
||
leads_this_month: number;
|
||
avg_lead_cost_rub: number | null;
|
||
runway_days: number | null;
|
||
}
|
||
|
||
export interface AdminTenantDetailResponse {
|
||
tenant: AdminTenant;
|
||
users: ApiTenantUser[];
|
||
projects: ApiTenantProject[];
|
||
balance_history: ApiTenantBalanceTx[];
|
||
aliveness: ApiTenantAliveness;
|
||
activity: ApiTenantActivityPage;
|
||
metrics: ApiTenantMetrics;
|
||
}
|
||
|
||
export async function getAdminTenantDetail(subdomain: string): Promise<AdminTenantDetailResponse> {
|
||
const { data } = await apiClient.get<AdminTenantDetailResponse>(
|
||
`/api/admin/tenants/${encodeURIComponent(subdomain)}`,
|
||
);
|
||
return data;
|
||
}
|
||
|
||
export async function getAdminTenantActivity(
|
||
subdomain: string,
|
||
offset: number,
|
||
limit = 50,
|
||
): Promise<ApiTenantActivityPage> {
|
||
const { data } = await apiClient.get<ApiTenantActivityPage>(
|
||
`/api/admin/tenants/${encodeURIComponent(subdomain)}/activity`,
|
||
{ params: { offset, limit } },
|
||
);
|
||
return data;
|
||
}
|
||
|
||
// === SaaS-admin → Биллинг: aggregates пополнений/списаний ===
|
||
|
||
export interface ApiAdminBillingTenant {
|
||
id: number;
|
||
subdomain: string;
|
||
organization_name: string;
|
||
contact_email: string;
|
||
status: string;
|
||
balance_rub: string;
|
||
tariff_id: number | null;
|
||
tariff_name: string | null;
|
||
mrr_rub: string;
|
||
monthly_topups_rub: string;
|
||
monthly_charges_rub: string;
|
||
last_payment_at: string | null;
|
||
chargeback_unrecovered_rub: string;
|
||
}
|
||
|
||
interface ApiAdminBillingSummary {
|
||
total_mrr_rub: string;
|
||
monthly_revenue_rub: string;
|
||
overdue_count: number;
|
||
refunds_count_30d: number;
|
||
}
|
||
|
||
export interface ListAdminBillingResponse {
|
||
tenants: ApiAdminBillingTenant[];
|
||
summary: ApiAdminBillingSummary;
|
||
}
|
||
|
||
export async function listAdminBilling(search = ''): Promise<ListAdminBillingResponse> {
|
||
const { data } = await apiClient.get<ListAdminBillingResponse>('/api/admin/billing', {
|
||
params: { search },
|
||
});
|
||
return data;
|
||
}
|
||
|
||
// === SaaS-admin → Реклама: расход и маржа по тенантам ===
|
||
|
||
export type AdvertisingSpendPeriod = 'current_month' | 'last_month' | '90d' | 'all';
|
||
|
||
export interface ApiAdminAdvertisingSpendRow {
|
||
tenant_id: number;
|
||
tenant_name: string;
|
||
client_spend_rub: string;
|
||
yandex_cost_rub: string;
|
||
our_margin_rub: string;
|
||
}
|
||
|
||
export interface ApiAdminAdvertisingSpendTotals {
|
||
client_spend_rub: string;
|
||
yandex_cost_rub: string;
|
||
our_margin_rub: string;
|
||
}
|
||
|
||
export interface AdminAdvertisingSpendResponse {
|
||
data: ApiAdminAdvertisingSpendRow[];
|
||
totals: ApiAdminAdvertisingSpendTotals;
|
||
ad_margin_percent: string;
|
||
}
|
||
|
||
export async function fetchAdvertisingSpend(
|
||
period: AdvertisingSpendPeriod = 'current_month',
|
||
): Promise<AdminAdvertisingSpendResponse> {
|
||
const { data } = await apiClient.get<AdminAdvertisingSpendResponse>('/api/admin/advertising/spend', {
|
||
params: { period },
|
||
});
|
||
return data;
|
||
}
|
||
|
||
export interface AdminAdvertisingSettings {
|
||
client_cpm_rub: string;
|
||
ad_margin_percent: string;
|
||
}
|
||
|
||
export async function fetchAdSettings(): Promise<AdminAdvertisingSettings> {
|
||
const { data } = await apiClient.get<AdminAdvertisingSettings>('/api/admin/advertising/settings');
|
||
return data;
|
||
}
|
||
|
||
export async function updateAdSettings(
|
||
payload: Partial<AdminAdvertisingSettings>,
|
||
): Promise<AdminAdvertisingSettings> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.put<AdminAdvertisingSettings>('/api/admin/advertising/settings', payload);
|
||
return data;
|
||
}
|
||
|
||
// === SaaS-admin → Реклама: кампании, ждущие номер креатива Яндекса (8e) ===
|
||
|
||
export interface AdminAwaitingCampaign {
|
||
id: number;
|
||
tenant_id: number;
|
||
tenant_name: string;
|
||
name: string;
|
||
status: string;
|
||
yandex_creative_id: number | null;
|
||
landing_url: string | null;
|
||
estimated_impressions: number | null;
|
||
}
|
||
|
||
export async function fetchAdAwaitingCampaigns(): Promise<AdminAwaitingCampaign[]> {
|
||
const { data } = await apiClient.get<{ data: AdminAwaitingCampaign[] }>('/api/admin/advertising/campaigns');
|
||
return data.data ?? [];
|
||
}
|
||
|
||
// === SaaS-admin → Реклама: «ждёт разбора» — сбойные задания робота (задача 15) ===
|
||
|
||
/**
|
||
* Задание робота, которое закрылось сбоем. Это НЕ обычный отказ модерации — те клиент
|
||
* разбирает сам по причине в переписке. Это место, где цепочка встала: робот сходил
|
||
* в кабинет и не понял, что видит, либо не смог отвезти картинки.
|
||
*/
|
||
export interface AdminStuckRobotJob {
|
||
id: number;
|
||
campaign_id: number;
|
||
tenant_id: number;
|
||
tenant_name: string;
|
||
campaign_name: string;
|
||
campaign_status: string;
|
||
kind: string;
|
||
yandex_ad_id: number | null;
|
||
failure_reason: string | null;
|
||
attempts: number;
|
||
finished_at: string | null;
|
||
}
|
||
|
||
export async function fetchAdRobotStuck(): Promise<AdminStuckRobotJob[]> {
|
||
const { data } = await apiClient.get<{ data: AdminStuckRobotJob[] }>('/api/admin/advertising/robot-stuck');
|
||
return data.data ?? [];
|
||
}
|
||
|
||
export async function setAdCampaignCreative(
|
||
id: number,
|
||
yandexCreativeId: number,
|
||
): Promise<{ id: number; yandex_creative_id: number }> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.patch<{ id: number; yandex_creative_id: number }>(
|
||
`/api/admin/advertising/campaigns/${id}/creative`,
|
||
{ yandex_creative_id: yandexCreativeId },
|
||
);
|
||
return data;
|
||
}
|
||
|
||
// === SaaS-admin → Инциденты ===
|
||
|
||
export interface ApiAdminIncident {
|
||
id: number;
|
||
incident_id: string;
|
||
type: string;
|
||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||
summary: string;
|
||
started_at: string;
|
||
detected_at: string;
|
||
resolved_at: string | null;
|
||
status: 'open' | 'investigating' | 'resolved';
|
||
affected_tenants_count: number;
|
||
affected_users_count: number | null;
|
||
rkn_notified: boolean;
|
||
rkn_notified_at: string | null;
|
||
rkn_deadline_at: string | null;
|
||
}
|
||
|
||
interface ApiAdminIncidentsSummary {
|
||
open: number;
|
||
investigating: number;
|
||
rkn_pending: number;
|
||
total_unresolved: number;
|
||
}
|
||
|
||
export interface ListAdminIncidentsParams {
|
||
type?: string;
|
||
severity?: string;
|
||
unresolved_only?: boolean;
|
||
limit?: number;
|
||
offset?: number;
|
||
}
|
||
|
||
export interface ListAdminIncidentsResponse {
|
||
incidents: ApiAdminIncident[];
|
||
total: number;
|
||
limit: number;
|
||
offset: number;
|
||
summary: ApiAdminIncidentsSummary;
|
||
}
|
||
|
||
export async function listAdminIncidents(params: ListAdminIncidentsParams = {}): Promise<ListAdminIncidentsResponse> {
|
||
const { data } = await apiClient.get<ListAdminIncidentsResponse>('/api/admin/incidents', { params });
|
||
return data;
|
||
}
|
||
|
||
// === SaaS-admin → Система: system_settings edit-flow ===
|
||
|
||
export interface SystemSetting {
|
||
key: string;
|
||
value: string;
|
||
type: 'int' | 'string' | 'decimal' | 'bool' | 'json';
|
||
description: string | null;
|
||
updated_at: string;
|
||
updated_by: number | null;
|
||
}
|
||
|
||
export async function listSystemSettings(): Promise<SystemSetting[]> {
|
||
const { data } = await apiClient.get<{ settings: SystemSetting[] }>('/api/admin/system-settings');
|
||
return data.settings;
|
||
}
|
||
|
||
export interface Yandex360BalanceStatus {
|
||
balance: number | null;
|
||
updated_at: string | null;
|
||
}
|
||
|
||
/** Ручной баланс Яндекс 360 (почта): текущее значение + когда обновляли. */
|
||
export async function getYandex360Balance(): Promise<Yandex360BalanceStatus> {
|
||
const { data } = await apiClient.get<Yandex360BalanceStatus>('/api/admin/settings/yandex360-balance');
|
||
return data;
|
||
}
|
||
|
||
/** Сохранить ручной баланс Яндекс 360 (null — очистить). Возвращает новый статус. */
|
||
export async function setYandex360Balance(balance: number | null): Promise<Yandex360BalanceStatus> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.put<Yandex360BalanceStatus>('/api/admin/settings/yandex360-balance', { balance });
|
||
return data;
|
||
}
|
||
|
||
export interface UpdateSystemSettingPayload {
|
||
value: string;
|
||
reason: string; // ≥30 chars
|
||
admin_user_id?: number; // опц.: id админа проставляет бэкенд из сессии (saas_admin auth); клиент не диктует
|
||
}
|
||
|
||
export interface UpdateSystemSettingResponse {
|
||
key: string;
|
||
value: string;
|
||
previous_value: string;
|
||
updated_at: string;
|
||
message: string;
|
||
}
|
||
|
||
export async function updateSystemSetting(
|
||
key: string,
|
||
payload: UpdateSystemSettingPayload,
|
||
): Promise<UpdateSystemSettingResponse> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.put<UpdateSystemSettingResponse>(
|
||
`/api/admin/system-settings/${encodeURIComponent(key)}`,
|
||
payload,
|
||
);
|
||
return data;
|
||
}
|
||
|
||
// === SaaS-admin → Биллинг: row-actions (Sprint 3D G4) ===
|
||
|
||
export interface AdminTariffPlan {
|
||
id: number;
|
||
name: string;
|
||
price_monthly: string;
|
||
}
|
||
|
||
export async function listAdminTariffPlans(): Promise<AdminTariffPlan[]> {
|
||
const { data } = await apiClient.get<{ plans: AdminTariffPlan[] }>('/api/admin/billing/tariff-plans');
|
||
return data.plans;
|
||
}
|
||
|
||
export async function updateTenantStatus(
|
||
id: number,
|
||
status: 'active' | 'suspended',
|
||
reason: string,
|
||
): Promise<{ id: number; status: string }> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.patch<{ id: number; status: string }>(`/api/admin/billing/tenants/${id}/status`, {
|
||
status,
|
||
reason,
|
||
});
|
||
return data;
|
||
}
|
||
|
||
export async function refundTenant(
|
||
id: number,
|
||
amountRub: number,
|
||
reason: string,
|
||
): Promise<{ id: number; balance_rub: string; transaction_id: number }> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<{ id: number; balance_rub: string; transaction_id: number }>(
|
||
`/api/admin/billing/tenants/${id}/refund`,
|
||
{ amount_rub: amountRub, reason },
|
||
);
|
||
return data;
|
||
}
|
||
|
||
export async function updateTenantBalance(
|
||
id: number,
|
||
payload: { balance_rub: string; reason?: string },
|
||
): Promise<{ id: number; balance_rub: string; delta: string; transaction_id: number }> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.patch<{
|
||
id: number;
|
||
balance_rub: string;
|
||
delta: string;
|
||
transaction_id: number;
|
||
}>(`/api/admin/tenants/${id}/balance`, payload);
|
||
return data;
|
||
}
|
||
|
||
export async function changeTenantTariff(
|
||
id: number,
|
||
tariffId: number,
|
||
reason: string,
|
||
): Promise<{ id: number; tariff_id: number; tariff_name: string }> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.patch<{ id: number; tariff_id: number; tariff_name: string }>(
|
||
`/api/admin/billing/tenants/${id}/tariff`,
|
||
{ tariff_id: tariffId, reason },
|
||
);
|
||
return data;
|
||
}
|
||
|
||
// === SaaS-admin → Инциденты: detail-view + РКН-notify (Sprint 3D G5/G6) ===
|
||
|
||
export interface ApiIncidentAffectedTenant {
|
||
id: number;
|
||
organization_name: string;
|
||
}
|
||
|
||
export interface ApiAdminIncidentDetail {
|
||
id: number;
|
||
incident_id: string;
|
||
type: string;
|
||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||
summary: string;
|
||
root_cause: string | null;
|
||
postmortem_url: string | null;
|
||
started_at: string;
|
||
detected_at: string;
|
||
resolved_at: string | null;
|
||
status: 'open' | 'investigating' | 'resolved';
|
||
affected_tenants: ApiIncidentAffectedTenant[];
|
||
affected_users_count: number | null;
|
||
notification_sent_at: string | null;
|
||
rkn_notified: boolean;
|
||
rkn_notified_at: string | null;
|
||
rkn_deadline_at: string | null;
|
||
created_by_admin: string | null;
|
||
closed_by_admin: string | null;
|
||
created_at: string | null;
|
||
updated_at: string | null;
|
||
}
|
||
|
||
export async function getAdminIncidentDetail(id: number): Promise<ApiAdminIncidentDetail> {
|
||
const { data } = await apiClient.get<{ incident: ApiAdminIncidentDetail }>(`/api/admin/incidents/${id}`);
|
||
return data.incident;
|
||
}
|
||
|
||
export async function notifyIncidentRkn(id: number): Promise<ApiAdminIncidentDetail> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<{ incident: ApiAdminIncidentDetail }>(
|
||
`/api/admin/incidents/${id}/rkn-notify`,
|
||
{},
|
||
);
|
||
return data.incident;
|
||
}
|
||
|
||
// === SaaS-admin → Тарифная сетка (Plan 4 / Sprint 5C G3) ===
|
||
|
||
export interface AdminPricingTier {
|
||
tier_no: number;
|
||
leads_in_tier: number | null;
|
||
price_per_lead_kopecks: number;
|
||
effective_from: string;
|
||
}
|
||
|
||
export interface PricingTiersResponse {
|
||
active: AdminPricingTier[];
|
||
scheduled: Record<string, AdminPricingTier[]>;
|
||
}
|
||
|
||
export interface PricingTierEditorRow {
|
||
tier_no: number;
|
||
leads_in_tier: number | null;
|
||
price_rub: string;
|
||
}
|
||
|
||
export async function getPricingTiers(): Promise<PricingTiersResponse> {
|
||
const { data } = await apiClient.get<{ data: PricingTiersResponse }>('/api/admin/pricing-tiers');
|
||
return { active: data.data.active, scheduled: data.data.scheduled ?? {} };
|
||
}
|
||
|
||
export async function createPricingTiers(
|
||
tiers: PricingTierEditorRow[],
|
||
effectiveFrom?: string,
|
||
): Promise<{ effective_from: string }> {
|
||
await ensureCsrfCookie();
|
||
const payload: { tiers: PricingTierEditorRow[]; effective_from?: string } = { tiers };
|
||
if (effectiveFrom) payload.effective_from = effectiveFrom;
|
||
const { data } = await apiClient.post<{ effective_from: string }>('/api/admin/pricing-tiers', payload);
|
||
return data;
|
||
}
|
||
|
||
export async function deleteScheduledPricingTier(effectiveFrom: string): Promise<void> {
|
||
await ensureCsrfCookie();
|
||
await apiClient.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);
|
||
}
|
||
|
||
// === SaaS-admin → Цены поставщиков (Plan 4 / Sprint 5C G3) ===
|
||
|
||
export interface AdminSupplier {
|
||
id: number;
|
||
code: string;
|
||
name: string;
|
||
cost_rub: string;
|
||
quality_score: string;
|
||
is_active: boolean;
|
||
}
|
||
|
||
export async function getAdminSuppliers(): Promise<AdminSupplier[]> {
|
||
const { data } = await apiClient.get<{ data: AdminSupplier[] }>('/api/admin/suppliers');
|
||
return data.data;
|
||
}
|
||
|
||
export async function updateAdminSupplier(
|
||
id: number,
|
||
payload: { cost_rub: string; quality_score: string; is_active: boolean },
|
||
): Promise<AdminSupplier> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.patch<{ data: AdminSupplier }>(`/api/admin/suppliers/${id}`, payload);
|
||
return data.data;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 152-ФЗ: обращения субъектов ПДн
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface PdSubjectRequest {
|
||
id: number;
|
||
received_at: string;
|
||
subject_email: string | null;
|
||
subject_phone: string | null;
|
||
subject_full_name: string | null;
|
||
request_type: 'access' | 'rectification' | 'deletion' | 'objection';
|
||
description: string | null;
|
||
status: 'received' | 'in_progress' | 'completed' | 'rejected';
|
||
tenant_id: number | null;
|
||
assigned_admin_id: number | null;
|
||
response_text: string | null;
|
||
deadline_at: string;
|
||
completed_at: string | null;
|
||
processing_restricted: boolean;
|
||
}
|
||
|
||
export interface ListPdRequestsResponse {
|
||
data: PdSubjectRequest[];
|
||
total: number;
|
||
limit: number;
|
||
offset: number;
|
||
}
|
||
|
||
export interface CreatePdRequestPayload {
|
||
subject_email?: string;
|
||
subject_phone?: string;
|
||
subject_full_name?: string;
|
||
request_type: 'access' | 'rectification' | 'deletion' | 'objection';
|
||
description?: string;
|
||
tenant_id?: number | null;
|
||
}
|
||
|
||
/**
|
||
* Что обзвон убрал по требованию человека — З-2.5.
|
||
*
|
||
* `fayly_ostalis` и `materialy_ne_proverit` — НЕ обычные числа. Первое значит
|
||
* «файл жив, стереть не смогли», второе — «проверить было нечем: расшифровки у
|
||
* записи нет, а искать в чужих материалах можно только по её тексту». Оба
|
||
* обязаны быть видны оператору тревожно, а не строчкой в общем списке: зелёная
|
||
* галочка «выполнено» при живом голосе — то же враньё, что и отметка
|
||
* `completed` в базе.
|
||
*/
|
||
export interface ObzvonEraseCounts {
|
||
zvonki: number;
|
||
zvonki_zvuk: number;
|
||
zvonki_tekst: number;
|
||
zvonki_telefon: number;
|
||
spisok_nabora: number;
|
||
materialy: number;
|
||
materialy_zvuk: number;
|
||
materialy_tekst: number;
|
||
fayly_ostalis: number;
|
||
materialy_ne_proverit: number;
|
||
}
|
||
|
||
export interface EraseSubjectResult {
|
||
message: string;
|
||
// `webhook_log` здесь больше НЕТ: служба перестала его возвращать в мае,
|
||
// когда таблицу убрали миграцией 2026_05_24_140000_drop_legacy_webhook_artefacts.
|
||
// Поле оставалось в типе и в разметке и показывало оператору пустоту.
|
||
counts: { users: number; leads: number; deals: number; obzvon: ObzvonEraseCounts };
|
||
}
|
||
|
||
export async function listPdSubjectRequests(
|
||
params: { status?: string; request_type?: string; limit?: number; offset?: number } = {},
|
||
): Promise<ListPdRequestsResponse> {
|
||
const { data } = await apiClient.get<ListPdRequestsResponse>('/api/admin/pd-subject-requests', { params });
|
||
return data;
|
||
}
|
||
|
||
export async function createPdSubjectRequest(payload: CreatePdRequestPayload): Promise<PdSubjectRequest> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<{ data: PdSubjectRequest }>('/api/admin/pd-subject-requests', payload);
|
||
return data.data;
|
||
}
|
||
|
||
export async function executePdErasure(id: number, adminUserId?: number): Promise<EraseSubjectResult> {
|
||
await ensureCsrfCookie();
|
||
const payload = adminUserId !== undefined ? { admin_user_id: adminUserId } : {};
|
||
const { data } = await apiClient.post<EraseSubjectResult>(`/api/admin/pd-subject-requests/${id}/erase`, payload);
|
||
return data;
|
||
}
|
||
|
||
// --- Оплата по счёту (Этап 1): список счетов + ручная отметка оплаты ---
|
||
|
||
export interface AdminInvoiceRow {
|
||
id: number;
|
||
invoice_number: string;
|
||
amount_total: string;
|
||
status: string;
|
||
issued_at: string;
|
||
expires_at: string | null;
|
||
tenant_id: number;
|
||
tenant_name: string | null;
|
||
payer_name: string | null;
|
||
}
|
||
|
||
export interface ListAdminInvoicesParams {
|
||
status?: string;
|
||
search?: string;
|
||
page?: number;
|
||
per_page?: number;
|
||
}
|
||
|
||
export interface ListAdminInvoicesResponse {
|
||
data: AdminInvoiceRow[];
|
||
meta: { current_page: number; last_page: number; total: number; per_page: number };
|
||
}
|
||
|
||
export async function listAdminInvoices(params: ListAdminInvoicesParams = {}): Promise<ListAdminInvoicesResponse> {
|
||
const { data } = await apiClient.get<ListAdminInvoicesResponse>('/api/admin/invoices', { params });
|
||
return data;
|
||
}
|
||
|
||
export async function markInvoicePaid(id: number): Promise<void> {
|
||
await ensureCsrfCookie();
|
||
await apiClient.post(`/api/admin/invoices/${id}/mark-paid`);
|
||
}
|
||
|
||
// === SaaS-admin → СМС: тарифы и имена (Task 16b) ===
|
||
|
||
/** Ступень тарифа СМС (строка GET /api/admin/sms/tariffs, а также PUT-ответ). */
|
||
export interface AdminSmsTariff {
|
||
id: number;
|
||
min_qty: number;
|
||
price_rub: string;
|
||
}
|
||
|
||
/** Строка редактируемой ступени в PUT /api/admin/sms/tariffs (без id — набор заменяется целиком). */
|
||
export interface AdminSmsTariffRow {
|
||
min_qty: number;
|
||
price_rub: string;
|
||
}
|
||
|
||
/**
|
||
* Общие настройки клиентских СМС: платное имя отправителя и границы окна отправки
|
||
* (часть GET /api/admin/sms/tariffs, а также PUT /api/admin/sms/settings).
|
||
*
|
||
* Часы окна — в местном времени ПОЛУЧАТЕЛЯ, общие для всех клиентов портала.
|
||
*/
|
||
export interface AdminSmsSettings {
|
||
name_fee_rub_per_operator: string;
|
||
name_debt_grace_days: number;
|
||
quiet_from_hour: number;
|
||
quiet_to_hour: number;
|
||
/** Через сколько минут без движения считать рассылку зависшей (строка листа 4.3). */
|
||
stuck_after_minutes: number;
|
||
/** Сколько номеров клиент может прислать за одну загрузку базы (строка листа 4.6). */
|
||
max_upload_phones: number;
|
||
/**
|
||
* Кому вообще шлём — операторы, чьих абонентов берём в рассылку (строка листа
|
||
* 4.14). Пусто = список не задан, действует четвёрка по умолчанию.
|
||
* 🔴 Не путать со списком операторов, с которыми согласовано имя отправителя:
|
||
* от того считается плата за имя (В-150).
|
||
*/
|
||
allowed_operators: string[] | null;
|
||
}
|
||
|
||
/** Ответ GET /api/admin/sms/tariffs. */
|
||
export interface AdminSmsTariffsResponse {
|
||
tariffs: AdminSmsTariff[];
|
||
settings: AdminSmsSettings;
|
||
}
|
||
|
||
/** Ответ PUT /api/admin/sms/tariffs — заменённый набор ступеней. */
|
||
export interface AdminSmsTariffsUpdateResponse {
|
||
tariffs: AdminSmsTariff[];
|
||
}
|
||
|
||
/** Заявка на имя отправителя (строка GET /api/admin/sms/senders, а также ответ approve/reject/disable). */
|
||
export interface AdminSmsSender {
|
||
id: number;
|
||
tenant_id: number;
|
||
name: string;
|
||
name_type: string;
|
||
status: string;
|
||
monthly_fee_rub: string;
|
||
note: string | null;
|
||
doc_original_name: string | null;
|
||
consent_doc_original_name: string | null;
|
||
}
|
||
|
||
/**
|
||
* Ссылка на скачивание приложенного клиентом скана. kind: 'basis' —
|
||
* документ-основание (право на имя), 'consent' — подписанное согласие.
|
||
*/
|
||
export function smsSenderDocumentUrl(id: number, kind: 'basis' | 'consent'): string {
|
||
return `/api/admin/sms/senders/${id}/document/${kind}`;
|
||
}
|
||
|
||
/** GET /api/admin/sms/tariffs — ступени тарифа + настройки платного имени. */
|
||
export async function fetchSmsTariffs(): Promise<AdminSmsTariffsResponse> {
|
||
const { data } = await apiClient.get<AdminSmsTariffsResponse>('/api/admin/sms/tariffs');
|
||
return data;
|
||
}
|
||
|
||
/** PUT /api/admin/sms/tariffs — заменить весь набор ступеней тарифа СМС. */
|
||
export async function updateSmsTariffs(rows: AdminSmsTariffRow[]): Promise<AdminSmsTariffsUpdateResponse> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.put<AdminSmsTariffsUpdateResponse>('/api/admin/sms/tariffs', { rows });
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* PUT /api/admin/sms/settings — плата за имя/мес, срок долга до отключения и границы
|
||
* окна отправки.
|
||
*
|
||
* Поля передаются объектом, а не по порядку: рядом стоят три числа (дни долга и два часа),
|
||
* и перепутанный порядок дал бы тихую беду — «долг 20 дней, окно с 29 часов».
|
||
*/
|
||
export async function updateSmsSettings(payload: {
|
||
name_fee_rub_per_operator: string;
|
||
name_debt_grace_days: number;
|
||
quiet_from_hour?: number;
|
||
quiet_to_hour?: number;
|
||
stuck_after_minutes?: number;
|
||
allowed_operators?: string[];
|
||
max_upload_phones?: number;
|
||
}): Promise<AdminSmsSettings> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.put<AdminSmsSettings>('/api/admin/sms/settings', payload);
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* Строка сверки нашего расчёта расхода перед оператором с расчётом самого
|
||
* оператора (строка листа 5.7). Видит ТОЛЬКО владелец (В-201).
|
||
*
|
||
* 🔴 Пустое и ноль — РАЗНЫЕ состояния. `null` значит «числа нет»: либо оператор
|
||
* цену не сообщил, либо у нас не задана цена канала. Показывать вместо этого
|
||
* «0 ₽» нельзя — экран рисовал бы идеальную сходимость там, где сверять нечем
|
||
* (В-234, В-235, В-236). Разница считается ТОЛЬКО когда известны ОБА числа.
|
||
*/
|
||
export interface AdminSmsReconcileRow {
|
||
campaign_id: number;
|
||
tenant_id: number;
|
||
title: string;
|
||
created_at: string;
|
||
messages: number;
|
||
/**
|
||
* По скольким из них оператор назвал цену. Отчёты приходят постепенно, поэтому
|
||
* у свежей рассылки это число меньше `messages` — и разница ниже посчитана
|
||
* только по ним (находка приёмки Этапа 5).
|
||
*/
|
||
messages_reported: number;
|
||
/** Наш счёт частей — есть всегда. */
|
||
our_parts: number;
|
||
/** Счёт частей у оператора — null, если его ещё не спрашивали. */
|
||
provider_parts: number | null;
|
||
/** Сколько сообщений, где числа частей разошлись. */
|
||
parts_mismatch: number;
|
||
our_cost: string | null;
|
||
/**
|
||
* Наш расход по ТЕМ ЖЕ сообщениям, по которым отчитался оператор. Именно он
|
||
* сравнивается с его счётом; null — сравнивать не с чем.
|
||
*/
|
||
our_cost_reported: string | null;
|
||
our_price_known: boolean;
|
||
provider_cost: string | null;
|
||
provider_cost_known: boolean;
|
||
/** Счёт оператора минус наш расход ПО ОТЧИТАННОМУ. */
|
||
difference: string | null;
|
||
}
|
||
|
||
/** GET /api/admin/sms/reconcile — сверка расчётов по рассылкам. */
|
||
export async function fetchSmsReconcile(): Promise<AdminSmsReconcileRow[]> {
|
||
const { data } = await apiClient.get<{ rows: AdminSmsReconcileRow[] }>('/api/admin/sms/reconcile');
|
||
return data.rows;
|
||
}
|
||
|
||
/** GET /api/admin/sms/senders?status=... — список заявок на имя отправителя. */
|
||
export async function fetchSmsSenders(status?: string): Promise<AdminSmsSender[]> {
|
||
const { data } = await apiClient.get<{ senders: AdminSmsSender[] }>('/api/admin/sms/senders', {
|
||
params: status ? { status } : {},
|
||
});
|
||
return data.senders;
|
||
}
|
||
|
||
/** POST /api/admin/sms/senders/{id}/approve — подтвердить имя отправителя. */
|
||
export async function approveSmsSender(id: number): Promise<AdminSmsSender> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<AdminSmsSender>(`/api/admin/sms/senders/${id}/approve`);
|
||
return data;
|
||
}
|
||
|
||
/** POST /api/admin/sms/senders/{id}/reject — отклонить имя отправителя (с опциональной причиной). */
|
||
export async function rejectSmsSender(id: number, note?: string): Promise<AdminSmsSender> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<AdminSmsSender>(`/api/admin/sms/senders/${id}/reject`, {
|
||
...(note !== undefined ? { note } : {}),
|
||
});
|
||
return data;
|
||
}
|
||
|
||
/** POST /api/admin/sms/senders/{id}/disable — отключить активное имя отправителя. */
|
||
export async function disableSmsSender(id: number): Promise<AdminSmsSender> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<AdminSmsSender>(`/api/admin/sms/senders/${id}/disable`);
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* Номер общего стоп-листа портала (строки листа 1.4, 1.5). Внесён один раз —
|
||
* закрыт у ВСЕХ клиентов сразу. Это защита договора с МТС при жалобе.
|
||
*/
|
||
export interface AdminSmsGlobalOptout {
|
||
phone: string;
|
||
reason: string | null;
|
||
created_at: string;
|
||
}
|
||
|
||
/** GET /api/admin/sms/global-optouts — номера, закрытые администрацией. */
|
||
export async function fetchSmsGlobalOptouts(): Promise<AdminSmsGlobalOptout[]> {
|
||
const { data } = await apiClient.get<{ items: AdminSmsGlobalOptout[]; total: number }>(
|
||
'/api/admin/sms/global-optouts',
|
||
);
|
||
return data.items;
|
||
}
|
||
|
||
/** POST /api/admin/sms/global-optouts — закрыть номер у всех клиентов сразу. */
|
||
export async function addSmsGlobalOptout(
|
||
phone: string,
|
||
reason: string | null,
|
||
): Promise<AdminSmsGlobalOptout> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<AdminSmsGlobalOptout>('/api/admin/sms/global-optouts', {
|
||
phone,
|
||
reason,
|
||
});
|
||
return data;
|
||
}
|
||
|
||
/** DELETE /api/admin/sms/global-optouts/{phone} — снова разрешить номер. */
|
||
export async function deleteSmsGlobalOptout(phone: string): Promise<void> {
|
||
await ensureCsrfCookie();
|
||
await apiClient.delete(`/api/admin/sms/global-optouts/${phone}`);
|
||
}
|
||
|
||
// --- Клиентская Telegram-реклама: тарифная сетка (задача 4.4) ---------------
|
||
// Зеркало SMS-функций выше. Ступень = ₽ за показ по объёму (у нас — потолок/оценка).
|
||
|
||
/** Вид медиа объявления — от него зависит цена показа у МТС. */
|
||
export type TgMediaKind = 'none' | 'image' | 'video';
|
||
|
||
/**
|
||
* Себестоимость показа Telegram-рекламы, ₽ за один показ (что платим МТС, с НДС).
|
||
* Одна строка на вид медиа: скидки за объём у поставщика нет (замер 02.08.2026).
|
||
*/
|
||
export interface AdminTgTariff {
|
||
id: number;
|
||
media_kind: TgMediaKind;
|
||
price_rub: string;
|
||
}
|
||
|
||
/** Строка в PUT /api/admin/telegram/tariffs — нужны ровно три, по одной на вид медиа. */
|
||
export interface AdminTgTariffRow {
|
||
media_kind: TgMediaKind;
|
||
price_rub: string;
|
||
}
|
||
|
||
/** Ответ GET /api/admin/telegram/tariffs. */
|
||
export interface AdminTgTariffsResponse {
|
||
tariffs: AdminTgTariff[];
|
||
}
|
||
|
||
/** Ответ PUT /api/admin/telegram/tariffs — заменённый набор ступеней. */
|
||
export interface AdminTgTariffsUpdateResponse {
|
||
tariffs: AdminTgTariff[];
|
||
}
|
||
|
||
/** GET /api/admin/telegram/tariffs — ступени тарифа. */
|
||
export async function fetchTgTariffs(): Promise<AdminTgTariffsResponse> {
|
||
const { data } = await apiClient.get<AdminTgTariffsResponse>('/api/admin/telegram/tariffs');
|
||
return data;
|
||
}
|
||
|
||
/** PUT /api/admin/telegram/tariffs — заменить весь набор ступеней. */
|
||
export async function updateTgTariffs(rows: AdminTgTariffRow[]): Promise<AdminTgTariffsUpdateResponse> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.put<AdminTgTariffsUpdateResponse>('/api/admin/telegram/tariffs', { rows });
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* Застрявшая телеграм-кампания (дефект Д-1, 06.08.2026).
|
||
*
|
||
* Робот довёл её до кабинета МТС и бросил на полпути. До появления разбора такая
|
||
* кампания застревала навсегда вместе с замороженными деньгами клиента.
|
||
* `mts_cost_rub` пуст — значит до кассы кабинета не дошли и списывать нечего.
|
||
*/
|
||
export interface AdminTgZastryavshaya {
|
||
id: number;
|
||
tenant_id: number;
|
||
status: string;
|
||
status_reason: string | null;
|
||
mts_campaign_id: string | null;
|
||
estimated_cost_rub: string | null;
|
||
mts_cost_rub: string | null;
|
||
/**
|
||
* 🔴 Сколько заперто ПРЯМО СЕЙЧАС, а не смета: заморозку могли уже отпустить.
|
||
* Необязательное — старый портал этого поля не отдавал, читаем как ноль.
|
||
*/
|
||
zamorozheno_rub?: string | null;
|
||
planned_count: number | null;
|
||
zastryala_s: string | null;
|
||
}
|
||
|
||
export interface AdminTgZastryavshieResponse {
|
||
campaigns: AdminTgZastryavshaya[];
|
||
}
|
||
|
||
/** Решение человека: рекламы не было (вернуть) либо реклама пошла (списать по факту). */
|
||
export type TgRazborReshenie = 'vernut' | 'spisat';
|
||
|
||
export interface AdminTgRazborResponse {
|
||
campaign: {
|
||
id: number;
|
||
status: string;
|
||
actual_cost_rub: string | null;
|
||
};
|
||
}
|
||
|
||
/** GET /api/admin/telegram/zastryavshie — кампании, ждущие ручного разбора. */
|
||
export async function fetchTgZastryavshie(): Promise<AdminTgZastryavshieResponse> {
|
||
const { data } = await apiClient.get<AdminTgZastryavshieResponse>('/api/admin/telegram/zastryavshie');
|
||
return data;
|
||
}
|
||
|
||
/** POST /api/admin/telegram/zastryavshie/{id}/razbor — исполнить решение по кампании. */
|
||
export async function razobratTgKampaniyu(
|
||
id: number,
|
||
reshenie: TgRazborReshenie,
|
||
): Promise<AdminTgRazborResponse> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<AdminTgRazborResponse>(
|
||
`/api/admin/telegram/zastryavshie/${id}/razbor`,
|
||
{ reshenie },
|
||
);
|
||
return data;
|
||
}
|