c7fd90c08d
DealsView крашился (Cannot read properties of undefined reading 'map'): listProjects() читал data.projects, но ProjectController::index() отдаёт { data: [...] } после миграции на JsonResource — availableProjects=undefined ломал .map, фильтр «Проект» был пуст. Фикс: читать data.data ?? []. + deals-api.spec.ts тест на новый конверт + защитный []. + DealDetailHero.spec.ts: фикстуры LeadStatus (isSystem/sortOrder вместо order) — устранён pre-existing type-check error.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
281 lines
8.5 KiB
TypeScript
281 lines
8.5 KiB
TypeScript
import { apiClient, ensureCsrfCookie } from './client';
|
|
|
|
/**
|
|
* API-вызовы для DealController.
|
|
*
|
|
* На MVP без auth — tenant_id передаётся параметром (на prod возьмётся из
|
|
* middleware → backend параметр исчезнет).
|
|
*/
|
|
|
|
export interface CreateDealPayload {
|
|
tenant_id: number;
|
|
project_name: string;
|
|
phone: string;
|
|
contact_name?: string;
|
|
status?: string;
|
|
manager_id?: number;
|
|
comment?: string;
|
|
}
|
|
|
|
export interface CreatedDeal {
|
|
id: number;
|
|
tenant_id: number;
|
|
project_id: number;
|
|
phone: string;
|
|
status: string;
|
|
contact_name: string | null;
|
|
manager_id: number | null;
|
|
received_at: string;
|
|
}
|
|
|
|
export async function createDeal(payload: CreateDealPayload): Promise<CreatedDeal> {
|
|
await ensureCsrfCookie();
|
|
const { data } = await apiClient.post<{ deal: CreatedDeal; message: string }>('/api/deals', payload);
|
|
return data.deal;
|
|
}
|
|
|
|
export interface BulkDeleteDealsPayload {
|
|
tenant_id: number;
|
|
ids: number[];
|
|
}
|
|
|
|
export interface BulkDeleteDealsResponse {
|
|
deleted: number;
|
|
requested: number;
|
|
}
|
|
|
|
/** Bulk soft-delete. Возвращает {deleted, requested} — deleted может быть < requested
|
|
* если часть id чужие или уже удалены (NO-OP). */
|
|
export async function bulkDeleteDeals(payload: BulkDeleteDealsPayload): Promise<BulkDeleteDealsResponse> {
|
|
await ensureCsrfCookie();
|
|
const { data } = await apiClient.delete<BulkDeleteDealsResponse>('/api/deals', { data: payload });
|
|
return data;
|
|
}
|
|
|
|
export interface BulkRestoreDealsPayload {
|
|
tenant_id: number;
|
|
ids: number[];
|
|
}
|
|
|
|
export interface BulkRestoreDealsResponse {
|
|
restored: number;
|
|
requested: number;
|
|
}
|
|
|
|
/** Bulk restore soft-deleted сделок. NO-OP idempotent для не-удалённых. */
|
|
export async function bulkRestoreDeals(payload: BulkRestoreDealsPayload): Promise<BulkRestoreDealsResponse> {
|
|
await ensureCsrfCookie();
|
|
const { data } = await apiClient.post<BulkRestoreDealsResponse>('/api/deals/restore', payload);
|
|
return data;
|
|
}
|
|
|
|
export interface ExportDealsPayload {
|
|
tenant_id: number;
|
|
ids: number[];
|
|
format?: 'csv' | 'xlsx';
|
|
}
|
|
|
|
export interface UpdateDealPayload {
|
|
tenant_id: number;
|
|
comment?: string | null;
|
|
manager_id?: number | null;
|
|
status?: string;
|
|
}
|
|
|
|
export async function updateDeal(id: number, payload: UpdateDealPayload): Promise<ApiDealDetail> {
|
|
await ensureCsrfCookie();
|
|
const { data } = await apiClient.patch<{ deal: ApiDealDetail }>(`/api/deals/${id}`, payload);
|
|
return data.deal;
|
|
}
|
|
|
|
export interface TransitionDealsPayload {
|
|
tenant_id: number;
|
|
ids: number[];
|
|
status: string;
|
|
}
|
|
|
|
export interface TransitionDealsResponse {
|
|
updated: number;
|
|
requested: number;
|
|
status: string;
|
|
}
|
|
|
|
/** Bulk status-update. Возвращает {updated, requested} — updated может быть < requested
|
|
* если часть id'шников чужие или уже в этом статусе (NO-OP). */
|
|
export async function transitionDeals(payload: TransitionDealsPayload): Promise<TransitionDealsResponse> {
|
|
await ensureCsrfCookie();
|
|
const { data } = await apiClient.post<TransitionDealsResponse>('/api/deals/transition', payload);
|
|
return data;
|
|
}
|
|
|
|
/** CSV-export. Возвращает строку с BOM — caller заворачивает в Blob+download. */
|
|
export async function exportDeals(payload: ExportDealsPayload): Promise<string> {
|
|
await ensureCsrfCookie();
|
|
const { data } = await apiClient.post<string>(
|
|
'/api/deals/export',
|
|
{ ...payload, format: 'csv' },
|
|
{ responseType: 'text' },
|
|
);
|
|
return data;
|
|
}
|
|
|
|
/** XLSX-export. Возвращает Blob (binary) — caller триггерит download. */
|
|
export async function exportDealsXlsx(payload: Omit<ExportDealsPayload, 'format'>): Promise<Blob> {
|
|
await ensureCsrfCookie();
|
|
const { data } = await apiClient.post<Blob>(
|
|
'/api/deals/export',
|
|
{ ...payload, format: 'xlsx' },
|
|
{ responseType: 'blob' },
|
|
);
|
|
return data;
|
|
}
|
|
|
|
export interface ExportDealsByRangePayload {
|
|
tenant_id: number;
|
|
received_from?: string;
|
|
received_to?: string;
|
|
format: 'csv' | 'xlsx';
|
|
}
|
|
|
|
/**
|
|
* Экспорт сделок по диапазону дат поставки. format='xlsx' → Blob, 'csv' → строка.
|
|
*/
|
|
export async function exportDealsByRange(payload: ExportDealsByRangePayload): Promise<Blob | string> {
|
|
await ensureCsrfCookie();
|
|
if (payload.format === 'xlsx') {
|
|
const { data } = await apiClient.post<Blob>('/api/deals/export', payload, { responseType: 'blob' });
|
|
return data;
|
|
}
|
|
const { data } = await apiClient.post<string>('/api/deals/export', payload, { responseType: 'text' });
|
|
return data;
|
|
}
|
|
|
|
export interface ApiDeal {
|
|
id: number;
|
|
tenant_id: number;
|
|
project_id: number;
|
|
project_name: string | null;
|
|
phone: string;
|
|
contact_name: string | null;
|
|
status: string;
|
|
manager_id: number | null;
|
|
manager_name: string | null;
|
|
manager_initials: string | null;
|
|
received_at: string | null;
|
|
comment: string | null;
|
|
city: string | null;
|
|
project_signal_type: string | null;
|
|
project_signal_identifier?: string | null;
|
|
project_sms_keyword?: string | null;
|
|
project_sms_senders?: string[] | null;
|
|
next_reminder_at: string | null;
|
|
}
|
|
|
|
export interface ApiDealEvent {
|
|
id: number;
|
|
event: string;
|
|
context: Record<string, unknown> | null;
|
|
created_at: string | null;
|
|
actor: { id: number; name: string; initials: string } | null;
|
|
}
|
|
|
|
export interface ApiDealDetail extends ApiDeal {
|
|
comment: string | null;
|
|
assigned_at: string | null;
|
|
}
|
|
|
|
export interface GetDealResponse {
|
|
deal: ApiDealDetail;
|
|
events: ApiDealEvent[];
|
|
}
|
|
|
|
export async function getDeal(id: number, tenantId: number): Promise<GetDealResponse> {
|
|
const { data } = await apiClient.get<GetDealResponse>(`/api/deals/${id}`, {
|
|
params: { tenant_id: tenantId },
|
|
});
|
|
return data;
|
|
}
|
|
|
|
export interface ListDealsParams {
|
|
tenantId: number;
|
|
statusIn?: string[];
|
|
projectId?: number;
|
|
managerId?: number;
|
|
search?: string;
|
|
/** Диапазон дат поставки (received_at). ISO-дата 'YYYY-MM-DD'. */
|
|
receivedFrom?: string;
|
|
receivedTo?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
/** «Корзина» — вернуть ТОЛЬКО soft-deleted сделки. */
|
|
onlyDeleted?: boolean;
|
|
}
|
|
|
|
export interface ListDealsResponse {
|
|
deals: ApiDeal[];
|
|
total: number;
|
|
limit: number;
|
|
offset: number;
|
|
}
|
|
|
|
export async function listDeals(params: ListDealsParams): Promise<ListDealsResponse> {
|
|
const { data } = await apiClient.get<ListDealsResponse>('/api/deals', {
|
|
params: {
|
|
tenant_id: params.tenantId,
|
|
status_in: params.statusIn,
|
|
project_id: params.projectId,
|
|
manager_id: params.managerId,
|
|
search: params.search,
|
|
received_from: params.receivedFrom,
|
|
received_to: params.receivedTo,
|
|
limit: params.limit,
|
|
offset: params.offset,
|
|
only_deleted: params.onlyDeleted ? 'true' : undefined,
|
|
},
|
|
});
|
|
return data;
|
|
}
|
|
|
|
export interface ApiManager {
|
|
id: number;
|
|
email: string;
|
|
first_name: string | null;
|
|
last_name: string | null;
|
|
name: string;
|
|
initials: string;
|
|
}
|
|
|
|
export async function listManagers(tenantId: number): Promise<ApiManager[]> {
|
|
const { data } = await apiClient.get<{ managers: ApiManager[] }>('/api/managers', {
|
|
params: { tenant_id: tenantId },
|
|
});
|
|
return data.managers;
|
|
}
|
|
|
|
export interface ApiProject {
|
|
id: number;
|
|
name: string;
|
|
tag: string | null;
|
|
type: string;
|
|
}
|
|
|
|
export async function listProjects(tenantId: number): Promise<ApiProject[]> {
|
|
// ProjectController::index() отдаёт { data: ProjectResource::collection(...) }.
|
|
// `?? []` — защита от undefined.map в DealsView при нештатном ответе.
|
|
const { data } = await apiClient.get<{ data: ApiProject[] }>('/api/projects', {
|
|
params: { tenant_id: tenantId },
|
|
});
|
|
return data.data ?? [];
|
|
}
|
|
|
|
/**
|
|
* Лёгкий count-only запрос для бейджа «Сделки» в AppSidebar (audit B2).
|
|
* Backend пропускает SELECT строк — отдаёт только COUNT(*).
|
|
*/
|
|
export async function fetchDealsCount(tenantId: number): Promise<number> {
|
|
const { data } = await apiClient.get<{ total: number }>('/api/deals', {
|
|
params: { tenant_id: tenantId, count_only: 1 },
|
|
});
|
|
return data.total;
|
|
}
|