Files
mkd/services/apiClient.ts
2026-02-04 00:17:04 +05:00

1456 lines
72 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { DomaApplication, CreateApplicationPayload, Outage, ApplicationComment, ApplicationHistoryEntry, District, Building, BuildingInventoryItem, PersonalAccount, Employee, Position, LegalDebtor, PreTrialWork, PreTrialAction, PromisedPayment, LegalContract, LegalCourtCase, DevPipelineItem, DevOSSSession, DevAuditData, DevMarketingActivity, PREvent, PREventPhoto, SMMChannel, SMMSubscriberSnapshot, SMMChannelsSummary, AttractionAction, PostTopic, ScheduledPost, User, UserPreferences, UserRole, CompanyNews, Department } from '../types';
export interface PortalUserRow {
id: number;
employeeId: string;
login: string;
email: string | null;
role: UserRole;
permissions?: string[] | null;
scope?: 'all' | 'own_district';
createdAt?: string;
employeeName: string;
employeePosition: string;
}
export interface PermissionTemplateRow {
id: number;
name: string;
description: string | null;
permissions: string[];
scope?: 'all' | 'own_district';
forPosition?: string | null;
suggestedRole?: UserRole | null;
createdAt?: string;
updatedAt?: string;
}
import { connectionService } from './connectionService';
import { offlineCacheService } from './offlineCacheService';
import { settingsService } from './settingsService';
// Базовый URL бэкенда берём из Vite-переменной окружения.
// Пример: VITE_API_BASE_URL="http://localhost:4000/api"
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
// Опциональный URL для n8n (отзывы, события PR). Если не задан, /pr/* идут на API_BASE_URL.
const N8N_BASE_URL = import.meta.env.VITE_N8N_BASE_URL || '';
function getBaseUrlForPath(path: string): string {
if (path.startsWith('/pr/') && N8N_BASE_URL) return N8N_BASE_URL;
return API_BASE_URL || '';
}
export const AUTH_TOKEN_KEY = 'mkd_auth_token';
export function getAuthToken(): string | null {
return localStorage.getItem(AUTH_TOKEN_KEY);
}
export function setAuthToken(token: string): void {
localStorage.setItem(AUTH_TOKEN_KEY, token);
}
export function clearAuth(): void {
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem('mkd_portalLogin');
window.dispatchEvent(new CustomEvent('mkd-auth-logout'));
}
/** Гостевой токен без учётки — для просмотра публичных отчётов. Запрос без Authorization. */
export async function fetchGuestToken(): Promise<string> {
const base = API_BASE_URL || '';
const url = base ? `${base.replace(/\/$/, '')}/auth/guest-token` : '/api/auth/guest-token';
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' } });
if (!res.ok) throw new Error('Не удалось получить гостевой токен');
const data = await res.json();
if (!data.token) throw new Error('Нет токена в ответе');
return data.token;
}
/** fetch with Authorization: Bearer token for /api requests (works with Vite proxy). */
export function authFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const token = getAuthToken();
const headers = new Headers(init?.headers);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return fetch(input, { ...init, headers });
}
if (!API_BASE_URL && !import.meta.env.DEV) {
console.warn(
'[apiClient] VITE_API_BASE_URL не задан. В dev используется /api (proxy).'
);
}
const isProduction = import.meta.env.PROD;
if (isProduction && API_BASE_URL && !API_BASE_URL.startsWith('https://')) {
console.warn(
'[apiClient] В production VITE_API_BASE_URL должен использовать HTTPS для защиты персональных данных (152-ФЗ).'
);
}
// Кэш GET-ответов (TTL 60 сек) для списков и справочников — меньше запросов при переключении вкладок
const GET_CACHE_TTL_MS = 60 * 1000;
const cacheablePathPrefixes = ['/buildings', '/districts'];
const responseCache = new Map<string, { data: unknown; expiresAt: number }>();
function getCacheKey(path: string, method: string): string | null {
if (method !== 'GET') return null;
const normalized = path.split('?')[0];
if (cacheablePathPrefixes.some((p) => normalized === p || normalized.startsWith(p + '/'))) {
return path;
}
return null;
}
export function clearGetCache(pathPrefix?: string): void {
if (!pathPrefix) {
responseCache.clear();
return;
}
for (const key of responseCache.keys()) {
const norm = key.split('?')[0];
if (norm === pathPrefix || norm.startsWith(pathPrefix + '/')) {
responseCache.delete(key);
}
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const baseUrl = getBaseUrlForPath(path);
if (!baseUrl) {
throw new Error('API_BASE_URL is not configured');
}
const url = `${baseUrl}${path}`;
const method = options.method || 'GET';
const body = options.body;
const cacheKey = getCacheKey(path, method);
if (cacheKey) {
const entry = responseCache.get(cacheKey);
if (entry && Date.now() < entry.expiresAt) {
return entry.data as T;
}
}
// Проверяем статус подключения
const connectionStatus = connectionService.getStatus();
const isOffline = connectionStatus === 'disconnected';
// Если нет подключения
if (isOffline) {
// DELETE запросы не кэшируются - требуют подключения
if (method === 'DELETE') {
throw new Error('Нет подключения к серверу. Удаление невозможно без подключения.');
}
// Запросы входа не кэшируем (не хранить пароль в localStorage)
if (path === '/auth/login' || path.startsWith('/auth/login')) {
throw new Error('Нет подключения к серверу. Вход невозможен без подключения.');
}
// Для POST/PUT запросов кэшируем
if (method === 'POST' || method === 'PUT') {
offlineCacheService.cacheRequest(method, path, body ? JSON.parse(body as string) : undefined);
return Promise.resolve({} as T);
}
// Для GET запросов просто выбрасываем ошибку
throw new Error('Нет подключения к серверу.');
}
const token = getAuthToken();
const headers: Record<string, string> = {};
// Не устанавливаем Content-Type для FormData - браузер сделает это сам с boundary
const isFormData = body instanceof FormData;
if (!isFormData) {
headers['Content-Type'] = 'application/json';
}
// Добавляем пользовательские заголовки
if (options.headers) {
Object.assign(headers, options.headers);
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
try {
const response = await fetch(url, {
...options,
headers,
});
if (response.status === 401) {
clearAuth();
}
if (!response.ok) {
// Если запрос к n8n вернул 500 и есть локальный бэкенд - пробуем fallback
const isN8nRequest = path.startsWith('/pr/') && N8N_BASE_URL && baseUrl === N8N_BASE_URL;
const isServerError = response.status >= 500 && response.status < 600;
if (isN8nRequest && isServerError && API_BASE_URL) {
console.warn(`[apiClient] n8n вернул ${response.status} для ${path}, пробуем локальный бэкенд...`);
try {
const fallbackUrl = `${API_BASE_URL}${path}`;
const fallbackResponse = await fetch(fallbackUrl, {
...options,
headers: { ...headers, ...(options.headers || {}) },
});
if (fallbackResponse.status === 401) {
clearAuth();
}
if (fallbackResponse.ok) {
const fallbackData = await fallbackResponse.json() as T;
if (cacheKey) {
responseCache.set(cacheKey, { data: fallbackData, expiresAt: Date.now() + GET_CACHE_TTL_MS });
}
return fallbackData;
}
} catch (fallbackError) {
console.warn('[apiClient] Fallback на локальный бэкенд также не удался:', fallbackError);
}
}
let errorMessage = `API error ${response.status}: ${response.statusText}`;
try {
const errorData = await response.json();
if (errorData.error) {
errorMessage = errorData.error;
} else if (errorData.message) {
errorMessage = errorData.message;
}
} catch {
const text = await response.text().catch(() => '');
if (text) {
try {
const parsed = JSON.parse(text);
errorMessage = parsed.error || parsed.message || errorMessage;
} catch {
if (text) errorMessage = text;
}
}
}
const error: any = new Error(errorMessage);
error.status = response.status;
error.response = response;
throw error;
}
const data = await response.json() as T;
if (cacheKey) {
responseCache.set(cacheKey, { data, expiresAt: Date.now() + GET_CACHE_TTL_MS });
}
if (method !== 'GET') {
if (path.startsWith('/buildings')) clearGetCache('/buildings');
if (path.startsWith('/districts')) clearGetCache('/districts');
}
return data;
} catch (error) {
// Если ошибка сети
if (error instanceof TypeError && error.message.includes('fetch')) {
// DELETE запросы не кэшируются
if (method === 'DELETE') {
throw new Error('Ошибка сети. Удаление невозможно без подключения.');
}
// Запросы входа не кэшируем (не хранить пароль)
if (path === '/auth/login' || path.startsWith('/auth/login')) {
throw new Error('Ошибка сети. Вход невозможен без подключения.');
}
// Для POST/PUT кэшируем
if (method === 'POST' || method === 'PUT') {
offlineCacheService.cacheRequest(method, path, body ? JSON.parse(body as string) : undefined);
return Promise.resolve({} as T);
}
}
throw error;
}
}
// Функция синхронизации кэшированных запросов
export async function syncCachedRequests(): Promise<{ success: number; failed: number }> {
if (!API_BASE_URL) {
return { success: 0, failed: 0 };
}
return await offlineCacheService.syncCachedRequests(API_BASE_URL);
}
export const apiClient = {
get: <T>(path: string) => request<T>(path, { method: 'GET' }),
post: <T>(path: string, body?: unknown, options?: RequestInit) =>
request<T>(path, {
method: 'POST',
body: body instanceof FormData ? body : (body ? JSON.stringify(body) : undefined),
...options,
}),
put: <T>(path: string, body?: unknown, options?: RequestInit) =>
request<T>(path, {
method: 'PUT',
body: body instanceof FormData ? body : (body ? JSON.stringify(body) : undefined),
...options,
}),
patch: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: 'PATCH',
body: body ? JSON.stringify(body) : undefined,
}),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};
export interface NotificationItem {
id: number;
type: string;
title: string;
body: string | null;
entityType: string | null;
entityId: string | null;
payload: Record<string, unknown> | null;
readAt: string | null;
createdAt: string;
}
// Примеры специализированных вызовов — чтобы было проще настраивать бэкенд.
// Вы можете использовать их или удалить, если не нужны.
export const backendApi = {
// GET /districts -> District[]
getDistricts: () => apiClient.get<District[]>('/districts'),
// POST /districts -> создать участок
createDistrict: (payload: { name: string; managerName: string; inventory?: BuildingInventoryItem[] }) =>
apiClient.post<District>('/districts', payload),
// PUT /districts/:id -> обновить участок (включая inventory)
updateDistrict: (id: string, payload: Partial<District>) =>
apiClient.put<District>(`/districts/${id}`, payload),
// DELETE /districts/:id -> удалить участок
deleteDistrict: (id: string) =>
apiClient.delete<{ success: boolean; message: string }>(`/districts/${id}`),
// Справочник должностей
getPositions: () => apiClient.get<Position[]>('/positions'),
createPosition: (payload: { name: string; isManagerial?: boolean; sortOrder?: number }) =>
apiClient.post<Position>('/positions', payload),
updatePosition: (id: string, payload: Partial<Pick<Position, 'name' | 'isManagerial' | 'sortOrder'>>) =>
apiClient.put<Position>(`/positions/${id}`, payload),
deletePosition: (id: string) => apiClient.delete(`/positions/${id}`),
// GET /buildings -> Building[]
getBuildings: () => apiClient.get<Building[]>('/buildings'),
// GET /buildings/:id -> один дом с accounts (для выбора жителей)
getBuilding: (id: string) => apiClient.get<Building>(`/buildings/${id}`),
// POST /buildings -> создать дом (ожидает полный объект Building)
createBuilding: (building: Building) =>
apiClient.post<Building>('/buildings', building),
// PUT /buildings/:id -> обновить дом (паспорт и др. поля)
updateBuilding: (building: Building) =>
apiClient.put<Building>(`/buildings/${building.id}`, building),
// DELETE /buildings/:id -> удалить дом
deleteBuilding: (id: string) =>
apiClient.delete<{ success: boolean; message: string }>(`/buildings/${id}`),
// GET /applications -> DomaApplication[]
getApplications: () => apiClient.get<DomaApplication[]>('/applications'),
// GET /applications/:id -> одна заявка (карточка)
getApplication: (id: number) => apiClient.get<DomaApplication>(`/applications/${id}`),
// PATCH /applications/:id -> обновление заявки (статус, срок, исполнитель и т.д.) + история
// statusReason обязателен при status=canceled или status=deferred; при deferred также нужен deadlineAt
updateApplication: (id: number, payload: { status?: string; deadlineAt?: string; statusReason?: string; executorName?: string; responsibleName?: string; observersText?: string; address?: string; buildingId?: string; apartment?: string; contactName?: string; contactPhone?: string; description?: string; changedBy?: string }) =>
apiClient.patch<DomaApplication>(`/applications/${id}`, payload),
// GET /applications/:id/history -> история изменений
getApplicationHistory: (id: number) => apiClient.get<ApplicationHistoryEntry[]>(`/applications/${id}/history`),
// GET /applications/:id/comments -> комментарии (?type=internal|resident)
getApplicationComments: (id: number, type?: 'internal' | 'resident') =>
apiClient.get<ApplicationComment[]>(`/applications/${id}/comments${type ? `?type=${type}` : ''}`),
// POST /applications/:id/comments -> добавить комментарий (internal — без жителя)
addApplicationComment: (id: number, payload: { text: string; type?: 'internal' | 'resident'; authorName?: string }) =>
apiClient.post<ApplicationComment>(`/applications/${id}/comments`, payload),
// POST /applications -> создание заявки вручную (диспетчерская)
createApplication: (payload: CreateApplicationPayload) =>
apiClient.post<DomaApplication>('/applications', payload),
// ========= ЖУРНАЛ ОТКЛЮЧЕНИЙ (OUTAGES) =========
getOutages: (params?: { buildingId?: string; active?: boolean }) => {
const search = new URLSearchParams();
if (params?.buildingId) search.set('buildingId', params.buildingId);
if (params?.active !== undefined) search.set('active', String(params.active));
const q = search.toString();
return apiClient.get<Outage[]>(`/outages${q ? `?${q}` : ''}`);
},
getOutage: (id: number) => apiClient.get<Outage>(`/outages/${id}`),
createOutage: (payload: import('../types').CreateOutagePayload) =>
apiClient.post<Outage | { created: number; items: Outage[] }>('/outages', payload),
updateOutage: (id: number, payload: { endAt?: string; type?: string; description?: string; active?: boolean; category?: string; problemDetail?: string; workType?: string; residentMessage?: string; generateNews?: boolean }) =>
apiClient.patch<Outage>(`/outages/${id}`, payload),
// ========= УВЕДОМЛЕНИЯ (КОЛОКОЛЬЧИК) =========
getNotifications: (params?: { limit?: number; offset?: number; type?: string }) => {
const search = new URLSearchParams();
if (params?.limit != null) search.set('limit', String(params.limit));
if (params?.offset != null) search.set('offset', String(params.offset));
if (params?.type) search.set('type', params.type);
const q = search.toString();
return apiClient.get<NotificationItem[]>(`/notifications${q ? `?${q}` : ''}`);
},
getUnreadCount: () => apiClient.get<{ count: number }>('/notifications/unread-count'),
markNotificationRead: (id: number) => apiClient.patch<{ id: number; readAt: string }>(`/notifications/${id}`),
markAllNotificationsRead: () => apiClient.patch<{ success: boolean }>('/notifications/read-all'),
// ========= НОВОСТИ КОМПАНИИ (company-news) =========
getCompanyNews: (params?: { status?: string; limit?: number; offset?: number }) => {
const search = new URLSearchParams();
if (params?.status) search.set('status', params.status);
if (params?.limit != null) search.set('limit', String(params.limit));
if (params?.offset != null) search.set('offset', String(params.offset));
const q = search.toString();
return apiClient.get<CompanyNews[]>(`/company-news${q ? `?${q}` : ''}`);
},
getCompanyNewsById: (id: number) => apiClient.get<CompanyNews>(`/company-news/${id}`),
createCompanyNews: (payload: { title: string; body?: string | null; status?: CompanyNews['status']; notifyDepartments?: Department[]; notifyEmployeeIds?: string[] }) =>
apiClient.post<CompanyNews>('/company-news', payload),
updateCompanyNews: (id: number, payload: { title?: string; body?: string | null; status?: CompanyNews['status']; notifyDepartments?: Department[]; notifyEmployeeIds?: string[] }) =>
apiClient.put<CompanyNews>(`/company-news/${id}`, payload),
deleteCompanyNews: (id: number) => apiClient.delete<{ success: boolean }>(`/company-news/${id}`),
// ========= DOMA.AI МАППИНГИ И ОЖИДАЮЩИЕ СОПОСТАВЛЕНИЯ =========
// POST/GET /doma/sync-now -> запустить синхронизацию заявок из Doma AI (требует авторизации или ?secret=DOMA_SYNC_SECRET)
domaSyncNow: () =>
apiClient.post<{ synced: number; message?: string }>('/doma/sync-now', {}),
// GET /doma/mappings -> существующие сопоставления адресов и сотрудников
getDomaMappings: () =>
apiClient.get<{
success: boolean;
data: {
addresses: Array<{
id: number;
domaAddress: string;
buildingId: string | null;
buildingAddress: string | null;
createdAt: string;
}>;
employees: Array<{
id: number;
domaName: string;
employeeId: string | null;
employeeName: string | null;
createdAt: string;
}>;
};
}>('/doma/mappings'),
// GET /doma/pending-mappings -> ожидающие сопоставления
getDomaPendingMappings: () =>
apiClient.get<{
success: boolean;
data: {
buildings: Array<{
id: number;
domaValue: string;
suggestedId?: string | null;
suggestedName?: string | null;
createdAt: string;
}>;
employees: Array<{
id: number;
domaValue: string;
suggestedId?: string | null;
suggestedName?: string | null;
createdAt: string;
}>;
};
}>('/doma/pending-mappings'),
// POST /doma/pending-mappings/:id/resolve -> ручное сопоставление
resolveDomaPendingMapping: (id: number, targetId: string) =>
apiClient.post<{ success: boolean }>(`/doma/pending-mappings/${id}/resolve`, {
targetId,
}),
// ========= АВТОРИЗАЦИЯ =========
login: (payload: { login: string; password: string; captchaToken?: string }) =>
apiClient.post<{ token: string; user: { id: string; userId: number; name: string; role: string; avatar: string | null } }>('/auth/login', payload),
getMe: () =>
apiClient.get<User>('/auth/me'),
updateProfile: (data: Partial<User>) =>
apiClient.put<User>('/auth/profile', data),
uploadProfilePhoto: async (file: File) => {
const form = new FormData();
form.append('photo', file);
const base = API_BASE_URL || '/api';
const r = await authFetch(`${base}${base.endsWith('/') ? '' : '/'}auth/profile/photo`, {
method: 'POST',
body: form,
});
if (!r.ok) {
const err = await r.json().catch(() => ({ error: r.statusText }));
throw new Error(err.error || r.statusText);
}
return r.json() as Promise<{ success: boolean; photoUrl: string }>;
},
deleteProfilePhoto: () =>
apiClient.delete<{ success: boolean }>('/auth/profile/photo'),
changePassword: (oldPassword: string, newPassword: string) =>
apiClient.put<{ success: boolean; message: string }>('/auth/change-password', { oldPassword, newPassword }),
getPreferences: () =>
apiClient.get<UserPreferences>('/auth/preferences'),
updatePreferences: (prefs: Partial<UserPreferences>) =>
apiClient.put<UserPreferences>('/auth/preferences', prefs),
// ========= ПАНЕЛЬ УПРАВЛЕНИЯ: ПОРТАЛЬНЫЕ ПОЛЬЗОВАТЕЛИ =========
getPortalUserByLogin: (login: string) =>
apiClient.get<PortalUserRow>(`/portal-users/me?login=${encodeURIComponent(login)}`),
getPortalUsers: () =>
apiClient.get<PortalUserRow[]>('/admin/portal-users'),
createPortalUser: (payload: { employeeId: string; login: string; email?: string; role?: string; password?: string }) =>
apiClient.post<PortalUserRow>('/admin/portal-users', payload),
updatePortalUserPassword: (id: number, password: string) =>
apiClient.put<{ success: boolean }>(`/admin/portal-users/${id}/password`, { password }),
createEmployeeWithUser: (payload: {
name: string;
position: string;
phone: string;
status?: string;
salary: number;
assignedDistrictId?: string;
login: string;
email?: string;
role?: string;
}) =>
apiClient.post<{ employee: { id: string; name: string; position: string; phone: string; status: string; salary: number; assignedDistrictId: string | null }; portalUser: PortalUserRow }>('/admin/employees-with-user', payload),
updatePortalUser: (id: number, payload: { login?: string; email?: string; role?: string; permissions?: string[] | null; scope?: 'all' | 'own_district' }) =>
apiClient.put<PortalUserRow>(`/admin/portal-users/${id}`, payload),
updatePortalUserPassword: (id: number, password: string) =>
apiClient.put<{ success: boolean }>(`/admin/portal-users/${id}/password`, { password }),
deletePortalUser: (id: number) =>
apiClient.delete<{ success: boolean }>(`/admin/portal-users/${id}`),
// GET/PUT настройки DaData (панель управления)
getDadataSettings: () =>
apiClient.get<{ enabled: boolean; apiKey: string; secret: string }>('/admin/integrations/dadata'),
saveDadataSettings: (payload: { enabled?: boolean; apiKey?: string; secret?: string }) =>
apiClient.put<{ success: boolean }>('/admin/integrations/dadata', payload),
getDomaSettings: () =>
apiClient.get<{ apiUrl: string; token: string }>('/admin/integrations/doma'),
saveDomaSettings: (payload: { apiUrl?: string; token?: string }) =>
apiClient.put<{ success: boolean }>('/admin/integrations/doma', payload),
getAIChatStatus: () =>
apiClient.get<{ enabled: boolean }>('/ai/status'),
getAIChatSettings: () =>
apiClient.get<{ enabled: boolean; url: string; apiKey: string }>('/admin/integrations/ai-chat'),
saveAIChatSettings: (payload: { enabled?: boolean; url?: string; apiKey?: string }) =>
apiClient.put<{ success: boolean }>('/admin/integrations/ai-chat', payload),
// Резервные копии БД
createBackup: () =>
apiClient.post<{ filename: string; createdAt: string }>('/admin/backup', {}),
getBackups: () =>
apiClient.get<Array<{ filename: string; createdAt: string }>>('/admin/backups'),
getBackupDownloadUrl: (filename: string) => {
const base = API_BASE_URL || '';
return `${base}/admin/backup/download/${encodeURIComponent(filename)}`;
},
// Дашборд безопасности: капча, логи входа, чёрный список, мониторинг
getSecuritySettings: () =>
apiClient.get<{ captchaEnabled: boolean; turnstileSiteKey: string | null; turnstileSecretKeySet: boolean }>('/admin/security/settings'),
saveSecuritySettings: (payload: { turnstileSiteKey?: string; turnstileSecretKey?: string }) =>
apiClient.put<{ captchaEnabled: boolean; turnstileSiteKey: string | null; turnstileSecretKeySet: boolean }>('/admin/security/settings', payload),
getCaptchaSiteKey: () =>
apiClient.get<{ siteKey: string | null }>('/auth/captcha-site-key'),
getSecurityLogs: (params?: { page?: number; limit?: number; success?: boolean; from?: string; to?: string }) => {
const q = new URLSearchParams();
if (params?.page != null) q.append('page', String(params.page));
if (params?.limit != null) q.append('limit', String(params.limit));
if (params?.success === true) q.append('success', 'true');
if (params?.success === false) q.append('success', 'false');
if (params?.from) q.append('from', params.from);
if (params?.to) q.append('to', params.to);
const query = q.toString();
return apiClient.get<{ items: Array<{ id: number; ip: string; loginMasked: string; success: boolean; createdAt: string }>; total: number; page: number; limit: number }>(
`/admin/security/logs${query ? `?${query}` : ''}`
);
},
getSecurityStats: () =>
apiClient.get<{ failedLast24h: number; totalAttemptsLast24h: number; blockedCount: number }>('/admin/security/stats'),
getSecurityBlacklist: () =>
apiClient.get<Array<{ id: number; type: string; value: string; reason: string | null; createdAt: string }>>('/admin/security/blacklist'),
addSecurityBlacklist: (payload: { type: 'ip' | 'login'; value: string; reason?: string }) =>
apiClient.post<{ id: number; type: string; value: string; reason: string | null; createdAt: string }>('/admin/security/blacklist', payload),
deleteSecurityBlacklist: (id: number) =>
apiClient.delete<{ success: boolean }>(`/admin/security/blacklist/${id}`),
// Шаблоны прав
getPermissionTemplates: () =>
apiClient.get<PermissionTemplateRow[]>('/admin/permission-templates'),
createPermissionTemplate: (payload: { name: string; description?: string; permissions: string[]; scope?: 'all' | 'own_district'; forPosition?: string | null; suggestedRole?: string | null }) =>
apiClient.post<PermissionTemplateRow>('/admin/permission-templates', payload),
updatePermissionTemplate: (id: number, payload: { name?: string; description?: string; permissions?: string[]; scope?: 'all' | 'own_district'; forPosition?: string | null; suggestedRole?: string | null }) =>
apiClient.put<PermissionTemplateRow>(`/admin/permission-templates/${id}`, payload),
deletePermissionTemplate: (id: number) =>
apiClient.delete<{ success: boolean }>(`/admin/permission-templates/${id}`),
// HR: вакансии и кандидаты (для очистки данных)
getVacancies: (params?: { status?: string; department?: string }) => {
const q = new URLSearchParams();
if (params?.status) q.append('status', params.status);
if (params?.department) q.append('department', params.department);
const query = q.toString();
return apiClient.get<any[]>(`/vacancies${query ? `?${query}` : ''}`);
},
deleteVacancy: (id: number) =>
apiClient.delete<{ message: string; vacancy: any }>(`/vacancies/${id}`),
getCandidates: (params?: { vacancyId?: number; stage?: string }) => {
const q = new URLSearchParams();
if (params?.vacancyId != null) q.append('vacancyId', String(params.vacancyId));
if (params?.stage) q.append('stage', params.stage);
const query = q.toString();
return apiClient.get<any[]>(`/candidates${query ? `?${query}` : ''}`);
},
deleteCandidate: (id: number) =>
apiClient.delete<{ message: string }>(`/candidates/${id}`),
// HR: типовые документы (кадры — печать/шаблоны)
getHrTemplateDocuments: () =>
apiClient.get<{ id: number; name: string; filePath: string; originalFilename: string | null; createdAt: string }[]>('/hr/template-documents'),
getHrTemplateDocumentDownloadUrl: (id: number) => {
const base = API_BASE_URL || '/api';
return (base.endsWith('/api') ? base : base + '/api') + `/hr/template-documents/${id}/download`;
},
uploadHrTemplateDocument: (formData: FormData) => {
const base = API_BASE_URL || '/api';
const url = base.endsWith('/api') ? `${base}/hr/template-documents` : `${base}/api/hr/template-documents`;
return authFetch(url, { method: 'POST', body: formData }).then((r) => r.json());
},
deleteHrTemplateDocument: (id: number) =>
apiClient.delete<{ success: boolean }>(`/hr/template-documents/${id}`),
// Обучение: программы (для очистки данных)
getTrainingPrograms: (params?: { type?: string; category?: string; isRequired?: boolean }) => {
const q = new URLSearchParams();
if (params?.type) q.append('type', params.type);
if (params?.category) q.append('category', params.category);
if (params?.isRequired === true) q.append('isRequired', 'true');
const query = q.toString();
return apiClient.get<any[]>(`/training/programs${query ? `?${query}` : ''}`);
},
deleteTrainingProgram: (id: number) =>
apiClient.delete<{ success?: boolean; message?: string }>(`/training/programs/${id}`),
// Финансы: категории и статьи расходов (для очистки данных)
getExpenseCategories: () =>
apiClient.get<any[]>('/finance/expense-categories'),
deleteExpenseCategory: (id: number) =>
apiClient.delete<{ success: boolean; message: string }>(`/finance/expense-categories/${id}`),
getExpenseItems: (params?: { categoryId?: number }) => {
const q = params?.categoryId != null ? `?categoryId=${params.categoryId}` : '';
return apiClient.get<any[]>(`/finance/expense-items${q}`);
},
deleteExpenseItem: (id: number) =>
apiClient.delete<{ success: boolean; message?: string }>(`/finance/expense-items/${id}`),
// Офис: заявки на ТМЦ, склад, документы (для очистки данных)
getSupplyRequests: (params?: { status?: string; category?: string }) => {
const q = new URLSearchParams();
if (params?.status) q.append('status', params.status);
if (params?.category) q.append('category', params.category);
const query = q.toString();
return apiClient.get<any[]>(`/office/supply-requests${query ? `?${query}` : ''}`);
},
deleteSupplyRequest: (id: number) =>
apiClient.delete<{ message: string; id: number }>(`/office/supply-requests/${id}`),
getOfficeInventory: (params?: { category?: string; search?: string }) => {
const q = new URLSearchParams();
if (params?.category) q.append('category', params.category);
if (params?.search) q.append('search', params.search);
const query = q.toString();
return apiClient.get<any[]>(`/office/inventory${query ? `?${query}` : ''}`);
},
deleteOfficeInventory: (id: number) =>
apiClient.delete<{ message: string; id: number }>(`/office/inventory/${id}`),
getOfficeDocuments: (params?: { status?: string; documentType?: string; search?: string }) => {
const q = new URLSearchParams();
if (params?.status) q.append('status', params.status);
if (params?.documentType) q.append('documentType', params.documentType);
if (params?.search) q.append('search', params.search);
const query = q.toString();
return apiClient.get<any[]>(`/office/documents${query ? `?${query}` : ''}`);
},
deleteOfficeDocument: (id: number) =>
apiClient.delete<{ success?: boolean; message?: string }>(`/office/documents/${id}`),
// Doma: удаление сопоставлений (для очистки данных)
deleteDomaAddressMapping: (id: number) =>
apiClient.delete<{ success: boolean }>(`/doma/mappings/address/${id}`),
deleteDomaEmployeeMapping: (id: number) =>
apiClient.delete<{ success: boolean }>(`/doma/mappings/employee/${id}`),
// PR: фото работ и мероприятия (для очистки данных)
getWorkPhotos: (params?: { building_id?: string; resident_report_id?: string; task_id?: string }) => {
const q = new URLSearchParams();
if (params?.building_id) q.append('building_id', params.building_id);
if (params?.resident_report_id) q.append('resident_report_id', params.resident_report_id);
if (params?.task_id) q.append('task_id', params.task_id);
const query = q.toString();
return apiClient.get<any[]>(`/pr/work-photos${query ? `?${query}` : ''}`);
},
deleteWorkPhoto: (id: number) =>
apiClient.delete<{ success?: boolean }>(`/pr/work-photos/${id}`),
// Загрузка данных: шаблоны и импорт
getImportTemplateUrl: (type: 'districts' | 'buildings' | 'employees' | 'accounts') => {
const base = API_BASE_URL || '';
return `${base}/admin/import/templates/${type}`;
},
importData: (type: 'districts' | 'buildings' | 'employees' | 'accounts', file: File) => {
const formData = new FormData();
formData.append('file', file);
const base = API_BASE_URL || '';
return fetch(`${base}/admin/import/${type}`, {
method: 'POST',
body: formData,
}).then(async (r) => {
if (!r.ok) {
const err = await r.json().catch(() => ({ error: r.statusText }));
throw new Error(err.error || r.statusText);
}
return r.json() as Promise<{ created: number; errors: Array<{ row: number; message: string }>; total: number }>;
});
},
// ========= СТАТИСТИКА ПРОИЗВОДИТЕЛЬНОСТИ =========
// GET /performance/overall -> общая статистика по организации
getOverallPerformance: () => apiClient.get<{
success: boolean;
data: {
total: number;
completed: number;
overdue: number;
inProgress: number;
completionRate: number;
overdueRate: number;
periodStart: string;
periodEnd: string;
};
}>('/performance/overall'),
// GET /performance/employees -> статистика по сотрудникам
getEmployeePerformance: () => apiClient.get<{
success: boolean;
data: Array<{
employeeName: string;
districtId: string | null;
districtName: string | null;
totalAssigned: number;
totalCompleted: number;
totalOverdue: number;
totalInProgress: number;
totalDeferred: number;
completionRate: number;
overdueRate: number;
performanceScore: number;
}>;
}>('/performance/employees'),
// GET /performance/districts -> статистика по участкам
getDistrictPerformance: () => apiClient.get<{
success: boolean;
data: Array<{
districtId: string;
districtName: string;
managerName: string;
totalApplications: number;
totalCompleted: number;
totalOverdue: number;
totalInProgress: number;
completionRate: number;
overdueRate: number;
averageScore: number;
}>;
}>('/performance/districts'),
// GET /production/metrics -> метрики производства (состояние домов, график работ, SLA)
getProductionMetrics: () => apiClient.get<{
success: boolean;
data: {
buildingCondition: {
value: number;
label: string;
condition: 'excellent' | 'good' | 'fair' | 'poor';
totalBuildings: number;
buildingsWithWear: number;
};
workSchedule: {
onTime: number;
late: number;
overdue: number;
onTimeRate: number;
lateRate: number;
};
slaRating: number;
};
}>('/production/metrics'),
// GET /production/dashboard -> полный дашборд для продвинутой статистики производства
getProductionDashboard: () => apiClient.get<{
success: boolean;
data: {
buildingCondition: {
value: number;
label: string;
condition: string;
totalBuildings: number;
buildingsWithWear: number;
};
workSchedule: { onTime: number; late: number; overdue: number; onTimeRate: number; lateRate: number };
slaRating: number;
applicationsTimeSlices: {
today: { created: number; closed: number; overdueNow: number };
week: { created: number; closed: number; overdueNow: number };
month: { created: number; closed: number; overdueNow: number };
};
applicationsSummary: {
new: number;
inProgress: number;
deferred: number;
done: number;
overdue: number;
completionRate: number;
};
districts: Array<{
districtId: string;
districtName: string;
managerName: string;
buildingCount: number;
totalApplications: number;
totalCompleted: number;
totalOverdue: number;
totalInProgress: number;
completionRate: number;
overdueRate: number;
averageScore: number;
}>;
topPerformers: Array<{ name: string; activeCount: number }>;
planWorks: {
total: number;
completed: number;
current: number;
carriedOver: number;
estimatedCostMonth: number;
byDistrict: Array<{ districtId: string; total: number; completed: number; buildingCount: number }>;
};
inspections: {
countMonth: number;
countQuarter: number;
lastDate: string | null;
byDistrict: Array<{ districtId: string; count: number; lastDate: string | null }>;
};
writeOffs: {
countToday: number;
countWeek: number;
countMonth: number;
sumMonth: number;
byDistrict: Array<{ districtId: string; count: number; sum: number }>;
};
meterRounds: {
countMonth: number;
lastDate: string | null;
byDistrict: Array<{ districtId: string; count: number; lastDate: string | null }>;
};
districtInventorySummary: Array<{
districtId: string;
districtName: string;
totalQuantity: number;
totalValue: number;
}>;
};
}>('/production/dashboard'),
// GET /pr/dashboard -> полный дашборд PR для продвинутой статистики и карточки сводки
getPRDashboard: () => apiClient.get<{
success: boolean;
data: {
npsCompany: number;
npsResponsesCount: number;
npsAvgScore: number;
npsPromoters: number;
npsPassives: number;
npsDetractors: number;
csatPercent: number;
reviewsAvgRating: number;
reviewsTotal: number;
smm: { total: number; byChannel: Array<{ id: number; name: string; type: string; subscribersCount: number }> };
reviewsStats: {
total: number;
new_count: number;
processed_count: number;
archived_count: number;
positive_count: number;
negative_count: number;
yandex_count: number;
gis2_count: number;
};
incidentsSummary: { total: number; open: number; in_progress: number; resolved: number };
events: { countMonth: number; upcoming: number; past: number };
residentReports: { total: number; publishedThisMonth: number };
workPhotos: { total: number; thisMonth: number };
npsSurveys: {
total: number;
active: number;
list: Array<{ id: number; title: string; responsesCount: number; status: string }>;
};
scheduledPosts: { draft: number; pending_approval: number; approved: number; published: number; rejected: number; edited: number };
negative: { openIncidents: number; negativeReviewsCount: number };
};
}>('/pr/dashboard'),
// GET /office/dashboard -> полный дашборд офиса для продвинутой статистики и карточки сводки
getOfficeDashboard: () => apiClient.get<{
success: boolean;
data: {
companyNews: { total: number; published: number; draft: number; pending: number; publishedThisMonth: number };
equipment: { total: number; byCondition: { good: number; fair: number; poor: number }; warrantyExpiringSoon: number };
repairRequests: { total: number; open: number; in_progress: number; completed: number };
supplyRequests: { total: number; byStatus: Record<string, number> };
inventory: { totalItems: number; lowStockCount: number };
documents: { total: number; incoming: number; outgoing: number; byStatus: Record<string, number> };
orders: { total: number; byStatus: Record<string, number> };
meetings: { thisWeek: number; upcoming: number };
meetingRooms: { total: number };
knowledgeBase: { categoriesCount: number; articlesCount: number };
};
}>('/office/dashboard'),
// PUT /applications/:number/status -> обновление статуса заявки
updateApplicationStatus: (
number: string,
status: string,
comment: string,
deferredUntil?: string
) => {
const settings = settingsService.getDomaAISettings();
return apiClient.put<{
success: boolean;
data: {
id: number;
number: string;
status: string;
};
}>(`/applications/${number}/status`, {
status,
comment,
deferredUntil,
domaApiUrl: settings?.apiUrl,
domaToken: settings?.token,
});
},
// POST /buildings/:id/accounts -> создание нового лицевого счета
createAccount: (buildingId: string, account: Partial<PersonalAccount>) =>
apiClient.post<PersonalAccount>(`/buildings/${buildingId}/accounts`, account),
// PUT /buildings/:id/accounts/:accountId -> обновление лицевого счета
updateAccount: (buildingId: string, account: PersonalAccount) =>
apiClient.put<PersonalAccount>(`/buildings/${buildingId}/accounts/${account.id}`, account),
// DELETE /buildings/:id/accounts/:accountId -> удаление лицевого счета
deleteAccount: (buildingId: string, accountId: string) =>
apiClient.delete<{ success: boolean; message: string }>(`/buildings/${buildingId}/accounts/${accountId}`),
// GET /employees -> Employee[]
getEmployees: () => apiClient.get<Employee[]>('/employees'),
getEmployeesList: () => apiClient.get<Array<{ id: string; name: string }>>('/employees/list'),
deleteEmployee: (id: string) =>
apiClient.delete<{ success: boolean; message: string }>(`/employees/${id}`),
// GET /responsibility -> зоны ответственности (section + subId -> employeeIds)
getResponsibility: (params?: { section?: string }) => {
const q = params?.section ? `?section=${encodeURIComponent(params.section)}` : '';
return apiClient.get<{ assignments: Array<{ employeeId: string; section: string; subId: string }> }>(`/responsibility${q}`);
},
// PUT /responsibility -> установить ответственных за зону
putResponsibility: (payload: { section: string; subId: string; employeeIds: string[] }) =>
apiClient.put<{ ok: boolean; section: string; subId: string; count: number }>('/responsibility', payload),
// ========= ЮРИДИЧЕСКИЙ ОТДЕЛ: ДОСУДЕБНАЯ РАБОТА =========
// GET /legal/debtors -> LegalDebtor[]
getLegalDebtors: (params?: { status?: string; buildingId?: string; search?: string }) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.append('status', params.status);
if (params?.buildingId) queryParams.append('buildingId', params.buildingId);
if (params?.search) queryParams.append('search', params.search);
const query = queryParams.toString();
return apiClient.get<LegalDebtor[]>(`/legal/debtors${query ? `?${query}` : ''}`);
},
// POST /legal/debtors -> создать должника
createLegalDebtor: (debtor: {
buildingId?: string;
apartment: string;
debtorName?: string;
phone?: string;
email?: string;
address: string;
debtAmount: number;
debtMonths: number;
}) => apiClient.post<LegalDebtor>('/legal/debtors', debtor),
// GET /legal/pre-trial-work -> PreTrialWork[]
getPreTrialWork: (params?: { status?: string; assignedTo?: string; search?: string }) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.append('status', params.status);
if (params?.assignedTo) queryParams.append('assignedTo', params.assignedTo);
if (params?.search) queryParams.append('search', params.search);
const query = queryParams.toString();
return apiClient.get<PreTrialWork[]>(`/legal/pre-trial-work${query ? `?${query}` : ''}`);
},
// POST /legal/pre-trial-work -> создать досудебную работу
createPreTrialWork: (work: {
debtorId: number;
assignedTo?: string;
notes?: string;
}) => apiClient.post<PreTrialWork>('/legal/pre-trial-work', work),
// PUT /legal/pre-trial-work/:id -> обновить досудебную работу
updatePreTrialWork: (id: number, updates: {
assignedTo?: string;
status?: string;
notes?: string;
promisedPaymentDate?: string;
promisedPaymentAmount?: number;
}) => apiClient.put<PreTrialWork>(`/legal/pre-trial-work/${id}`, updates),
// POST /legal/pre-trial-work/:id/actions -> добавить действие
addPreTrialAction: (workId: number, action: {
actionType: 'call' | 'letter' | 'visit';
actionDate?: string;
performedBy: string;
result?: string;
notes?: string;
attachments?: string[];
}) => apiClient.post<PreTrialAction>(`/legal/pre-trial-work/${workId}/actions`, action),
// POST /legal/pre-trial-work/:id/promised-payment -> добавить обещанную оплату
addPromisedPayment: (workId: number, payment: {
promisedDate: string;
promisedAmount: number;
notes?: string;
}) => apiClient.post<PromisedPayment>(`/legal/pre-trial-work/${workId}/promised-payment`, payment),
// PUT /legal/pre-trial-work/:id/transfer-to-court -> передать в суд
transferToCourt: (workId: number, courtCaseId?: string) =>
apiClient.put<PreTrialWork>(`/legal/pre-trial-work/${workId}/transfer-to-court`, { courtCaseId }),
// PUT /legal/promised-payments/:id/mark-paid -> отметить оплату как выполненную
markPromisedPaymentPaid: (paymentId: number, data: {
actualPaymentDate?: string;
actualPaymentAmount?: number;
}) => apiClient.put<PromisedPayment>(`/legal/promised-payments/${paymentId}/mark-paid`, data),
// ========= ПРОВЕРКА КОНТРАГЕНТОВ =========
// POST /legal/check-counterparty -> проверить контрагента по ИНН через DaData
checkCounterparty: (inn: string) =>
apiClient.post<any>('/legal/check-counterparty', { inn }),
// GET /legal/counterparties -> список проверенных контрагентов
getCounterparties: (params?: { riskLevel?: string; search?: string; limit?: number }) => {
const queryParams = new URLSearchParams();
if (params?.riskLevel) queryParams.append('riskLevel', params.riskLevel);
if (params?.search) queryParams.append('search', params.search);
if (params?.limit) queryParams.append('limit', params.limit.toString());
const query = queryParams.toString();
return apiClient.get<any[]>(`/legal/counterparties${query ? `?${query}` : ''}`);
},
// GET /legal/counterparties/:id -> получить детальный отчет о проверке
getCounterpartyReport: (id: number) => apiClient.get<any>(`/legal/counterparties/${id}`),
// ========= ЮРИДИЧЕСКИЙ ОТДЕЛ: ДОГОВОРЫ =========
// GET /legal/contracts -> список договоров
getLegalContracts: (params?: { status?: string; search?: string; viewMode?: string }) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.append('status', params.status);
if (params?.search) queryParams.append('search', params.search);
if (params?.viewMode) queryParams.append('viewMode', params.viewMode);
const query = queryParams.toString();
return apiClient.get<LegalContract[]>(`/legal/contracts${query ? `?${query}` : ''}`);
},
// POST /legal/contracts -> создать договор
createLegalContract: (contract: Partial<LegalContract> & { number: string; type: string; counterparty: string; amount: number; startDate: string; endDate: string; manager: string }) =>
apiClient.post<LegalContract>('/legal/contracts', contract),
// PUT /legal/contracts/:id -> обновить договор
updateLegalContract: (id: string, updates: Partial<LegalContract>) =>
apiClient.put<LegalContract>(`/legal/contracts/${id}`, updates),
// GET /legal/contracts/:id -> получить договор
getLegalContract: (id: string) => apiClient.get<LegalContract>(`/legal/contracts/${id}`),
// ========= ЮРИДИЧЕСКИЙ ОТДЕЛ: СУДЕБНЫЕ ДЕЛА =========
// GET /legal/court-cases -> список судебных дел
getLegalCourtCases: (params?: { status?: string; type?: string; role?: string; search?: string }) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.append('status', params.status);
if (params?.type) queryParams.append('type', params.type);
if (params?.role) queryParams.append('role', params.role);
if (params?.search) queryParams.append('search', params.search);
const query = queryParams.toString();
return apiClient.get<LegalCourtCase[]>(`/legal/court-cases${query ? `?${query}` : ''}`);
},
// POST /legal/court-cases -> создать судебное дело
createLegalCourtCase: (courtCase: Partial<LegalCourtCase> & { caseNumber: string; type: string; role: string; subject: string; amount: number }) =>
apiClient.post<LegalCourtCase>('/legal/court-cases', courtCase),
// PUT /legal/court-cases/:id -> обновить судебное дело
updateLegalCourtCase: (id: string, updates: Partial<LegalCourtCase>) =>
apiClient.put<LegalCourtCase>(`/legal/court-cases/${id}`, updates),
// GET /legal/court-cases/:id -> получить судебное дело
getLegalCourtCase: (id: string) => apiClient.get<LegalCourtCase>(`/legal/court-cases/${id}`),
// ========= МОДУЛЬ РАЗВИТИЯ =========
// Pipeline (Воронка)
getDevelopmentPipeline: (params?: { status?: string; search?: string }) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.append('status', params.status);
if (params?.search) queryParams.append('search', params.search);
const query = queryParams.toString();
return apiClient.get<DevPipelineItem[]>(`/development/pipeline${query ? `?${query}` : ''}`);
},
createDevelopmentPipeline: (item: Omit<DevPipelineItem, 'id'> & { id?: string; expected_revenue?: number }) => {
const id = item.id || `p-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Преобразуем camelCase в snake_case для бэкенда
const payload: any = {
id,
address: item.address,
type: item.type,
floors: item.floors,
area: item.area,
apartments: item.apartments,
status: item.status,
probability: item.probability,
expected_revenue: item.expected_revenue || item.expectedRevenue || 0,
manager: item.manager,
notes: item.notes || null,
};
return apiClient.post<DevPipelineItem>('/development/pipeline', payload);
},
updateDevelopmentPipeline: (id: string, updates: Partial<DevPipelineItem>) => {
const payload: Record<string, unknown> = {};
if (updates.address !== undefined) payload.address = updates.address;
if (updates.type !== undefined) payload.type = updates.type;
if (updates.floors !== undefined) payload.floors = updates.floors;
if (updates.area !== undefined) payload.area = updates.area;
if (updates.apartments !== undefined) payload.apartments = updates.apartments;
if (updates.status !== undefined) payload.status = updates.status;
if (updates.probability !== undefined) payload.probability = updates.probability;
if (updates.expectedRevenue !== undefined) payload.expected_revenue = updates.expectedRevenue;
if (updates.manager !== undefined) payload.manager = updates.manager;
if (updates.buildingId !== undefined) payload.building_id = updates.buildingId;
if (updates.notes !== undefined) payload.notes = updates.notes;
return apiClient.put<DevPipelineItem>(`/development/pipeline/${id}`, payload);
},
deleteDevelopmentPipeline: (id: string) =>
apiClient.delete<{ success: boolean; message: string }>(`/development/pipeline/${id}`),
// OSS (Собрания)
getDevelopmentOSS: (params?: { status?: string; building_id?: string }) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.append('status', params.status);
if (params?.building_id) queryParams.append('building_id', params.building_id);
const query = queryParams.toString();
return apiClient.get<DevOSSSession[]>(`/development/oss${query ? `?${query}` : ''}`);
},
createDevelopmentOSS: (session: Omit<DevOSSSession, 'id'> & { id?: string; building_id?: string | null; start_date?: string; end_date?: string; quorum_total?: number; agenda_items?: string[]; agendaItems?: string[] }) => {
const id = session.id || `oss-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const payload: any = {
id,
address: session.address,
building_id: session.building_id || session.buildingId || null,
start_date: session.start_date || session.startDate,
end_date: session.end_date || session.endDate,
quorum_total: session.quorum_total || session.quorumTotal,
quorum_current: 0,
status: session.status || 'planned',
type: session.type,
description: session.description || null,
};
const agenda = session.agendaItems ?? session.agenda_items;
if (agenda != null && Array.isArray(agenda) && agenda.length > 0) payload.agenda_items = agenda;
return apiClient.post<DevOSSSession>('/development/oss', payload);
},
updateDevelopmentOSS: (id: string, updates: Partial<DevOSSSession> & { agenda_items?: string[]; agendaItems?: string[] }) => {
const payload: Record<string, unknown> = {};
if (updates.address !== undefined) payload.address = updates.address;
if (updates.startDate !== undefined) payload.start_date = updates.startDate;
if (updates.endDate !== undefined) payload.end_date = updates.endDate;
if (updates.quorumTotal !== undefined) payload.quorum_total = updates.quorumTotal;
if (updates.quorumCurrent !== undefined) payload.quorum_current = updates.quorumCurrent;
if (updates.status !== undefined) payload.status = updates.status;
if (updates.type !== undefined) payload.type = updates.type;
if (updates.description !== undefined) payload.description = updates.description;
if (updates.agendaItems !== undefined) payload.agenda_items = updates.agendaItems;
if (updates.agenda_items !== undefined) payload.agenda_items = updates.agenda_items;
return apiClient.put<DevOSSSession>(`/development/oss/${id}`, payload);
},
updateOSSQuorum: (id: string, quorumCurrent: number) =>
apiClient.put<DevOSSSession>(`/development/oss/${id}/quorum`, { quorum_current: quorumCurrent }),
submitOSSBallot: (id: string, ballot: { apartment: string; owner_name?: string; area: number; vote_result?: string; notes?: string; votes_by_item?: Record<string, string> }) =>
apiClient.post<any>(`/development/oss/${id}/ballot`, ballot),
submitOSSBallotsBulk: (id: string, ballots: { apartment: string; owner_name?: string; area: number; vote_result?: string; notes?: string; votes_by_item?: Record<string, string> }[]) =>
apiClient.post<{ inserted: number; updated: number; total: number }>(`/development/oss/${id}/ballots/bulk`, { ballots }),
getOSSRegistry: (id: string) =>
apiClient.get<any[]>(`/development/oss/${id}/registry`),
// Audits (Аудиты)
getDevelopmentAudits: (params?: { building_id?: string }) => {
const queryParams = new URLSearchParams();
if (params?.building_id) queryParams.append('building_id', params.building_id);
const query = queryParams.toString();
return apiClient.get<DevAuditData[]>(`/development/audits${query ? `?${query}` : ''}`);
},
getDevelopmentAudit: (id: string) =>
apiClient.get<DevAuditData>(`/development/audits/${id}`),
createDevelopmentAudit: (audit: Omit<DevAuditData, 'id'> & { id?: string; building_id?: string | null; wear_percent?: number; roof_condition?: string; basement_condition?: string; calculated_tariff?: number; projected_margin?: number; audit_date?: string; auditor_name?: string | null }) => {
const id = audit.id || `a-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Преобразуем camelCase в snake_case для бэкенда
const payload: any = {
id,
address: audit.address,
building_id: audit.building_id || audit.buildingId || null,
wear_percent: audit.wear_percent !== undefined ? audit.wear_percent : audit.wearPercent,
roof_condition: audit.roof_condition || audit.roofCondition,
basement_condition: audit.basement_condition || audit.basementCondition,
calculated_tariff: audit.calculated_tariff !== undefined ? audit.calculated_tariff : audit.calculatedTariff,
projected_margin: audit.projected_margin !== undefined ? audit.projected_margin : audit.projectedMargin,
audit_date: audit.audit_date || audit.auditDate || new Date().toISOString().split('T')[0],
auditor_name: audit.auditor_name || audit.auditorName || null,
notes: audit.notes || null,
};
return apiClient.post<DevAuditData>('/development/audits', payload);
},
updateDevelopmentAudit: (id: string, updates: Partial<DevAuditData> & { inspection_data?: Record<string, unknown>; inspectionData?: Record<string, unknown> }) => {
const payload: Record<string, unknown> = {};
if (updates.address !== undefined) payload.address = updates.address;
if (updates.status !== undefined) payload.status = updates.status;
if (updates.wearPercent !== undefined) payload.wear_percent = updates.wearPercent;
if (updates.roofCondition !== undefined) payload.roof_condition = updates.roofCondition;
if (updates.basementCondition !== undefined) payload.basement_condition = updates.basementCondition;
if (updates.projectedMargin !== undefined) payload.projected_margin = updates.projectedMargin;
if (updates.complexityIndex !== undefined) payload.complexity_index = updates.complexityIndex;
if (updates.inspectionData !== undefined) payload.inspection_data = updates.inspectionData;
if (updates.inspection_data !== undefined) payload.inspection_data = updates.inspection_data;
if (updates.auditDate !== undefined) payload.audit_date = updates.auditDate;
if (updates.auditorName !== undefined) payload.auditor_name = updates.auditorName;
if (updates.notes !== undefined) payload.notes = updates.notes;
return apiClient.put<DevAuditData>(`/development/audits/${id}`, Object.keys(payload).length ? payload : updates);
},
getAuditDefectList: (id: string) =>
apiClient.get<any>(`/development/audits/${id}/defect-list`),
// Marketing (Маркетинг)
getDevelopmentMarketing: (params?: { status?: string; building_id?: string }) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.append('status', params.status);
if (params?.building_id) queryParams.append('building_id', params.building_id);
const query = queryParams.toString();
return apiClient.get<DevMarketingActivity[]>(`/development/marketing${query ? `?${query}` : ''}`);
},
createDevelopmentMarketing: (activity: Omit<DevMarketingActivity, 'id'> & { id?: string; building_id?: string | null; activists_count?: number; meetings_held?: number; ads_distributed?: number }) => {
const id = activity.id || `m-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Преобразуем camelCase в snake_case для бэкенда
const payload: any = {
id,
address: activity.address,
building_id: activity.building_id || activity.buildingId || null,
activists_count: activity.activists_count !== undefined ? activity.activists_count : activity.activistsCount || 0,
meetings_held: activity.meetings_held !== undefined ? activity.meetings_held : activity.meetingsHeld || 0,
ads_distributed: activity.ads_distributed !== undefined ? activity.ads_distributed : activity.adsDistributed || 0,
competitor: activity.competitor || null,
status: activity.status,
notes: activity.notes || null,
};
return apiClient.post<DevMarketingActivity>('/development/marketing', payload);
},
updateDevelopmentMarketing: (id: string, updates: Partial<DevMarketingActivity> & { activists_count?: number; meetings_held?: number; ads_distributed?: number }) => {
// Преобразуем camelCase в snake_case для бэкенда
const payload: any = {};
if (updates.address !== undefined) payload.address = updates.address;
if (updates.activistsCount !== undefined || updates.activists_count !== undefined) {
payload.activists_count = updates.activists_count !== undefined ? updates.activists_count : updates.activistsCount;
}
if (updates.meetingsHeld !== undefined || updates.meetings_held !== undefined) {
payload.meetings_held = updates.meetings_held !== undefined ? updates.meetings_held : updates.meetingsHeld;
}
if (updates.adsDistributed !== undefined || updates.ads_distributed !== undefined) {
payload.ads_distributed = updates.ads_distributed !== undefined ? updates.ads_distributed : updates.adsDistributed;
}
if (updates.competitor !== undefined) payload.competitor = updates.competitor;
if (updates.status !== undefined) payload.status = updates.status;
if (updates.notes !== undefined) payload.notes = updates.notes;
return apiClient.put<DevMarketingActivity>(`/development/marketing/${id}`, payload);
},
updateMarketingMetrics: (id: string, metrics: { activists_count?: number; meetings_held?: number; ads_distributed?: number }) =>
apiClient.put<DevMarketingActivity>(`/development/marketing/${id}/metrics`, metrics),
// Summary (Сводка)
getDevelopmentSummary: () =>
apiClient.get<{ growthM2: number; ossSuccessRate: number; cac: number; potentialRevenue: number }>('/development/summary'),
// Locations (Карта)
getDevelopmentLocations: (params?: { status?: string }) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.append('status', params.status);
const query = queryParams.toString();
return apiClient.get<any[]>(`/development/locations${query ? `?${query}` : ''}`);
},
createDevelopmentLocation: (location: { building_id?: string; address: string; latitude?: number; longitude?: number; status: string; competitor_name?: string }) =>
apiClient.post<any>('/development/locations', location),
// PR Мероприятия
getPREvents: (params?: { status?: string; type?: string; buildingId?: string; districtId?: string; from?: string; to?: string; limit?: number }) => {
const q = new URLSearchParams();
if (params?.status) q.append('status', params.status);
if (params?.type) q.append('type', params.type);
if (params?.buildingId) q.append('buildingId', params.buildingId);
if (params?.districtId) q.append('districtId', params.districtId);
if (params?.from) q.append('from', params.from);
if (params?.to) q.append('to', params.to);
if (params?.limit != null) q.append('limit', String(params.limit));
const query = q.toString();
return apiClient.get<PREvent[]>(`/pr/events${query ? `?${query}` : ''}`);
},
getPREvent: (id: string | number) => apiClient.get<PREvent>(`/pr/events/${id}`),
createPREvent: (payload: Partial<PREvent> & { title: string; date: string; type: PREvent['type']; category: PREvent['category']; assignedEmployeeIds?: string[] }) =>
apiClient.post<PREvent>('/pr/events', payload),
updatePREvent: (id: string | number, payload: Partial<PREvent> & { assignedEmployeeIds?: string[] }) =>
apiClient.put<PREvent>(`/pr/events/${id}`, payload),
deletePREvent: (id: string | number) => apiClient.delete<{ success: boolean }>(`/pr/events/${id}`),
getPREventPhotos: (eventId: string | number) => apiClient.get<PREventPhoto[]>(`/pr/events/${eventId}/photos`),
uploadPREventPhoto: (eventId: string | number, formData: FormData) =>
apiClient.post<PREventPhoto>(`/pr/events/${eventId}/photos`, formData),
deletePREventPhoto: (eventId: string | number, photoId: number) =>
apiClient.delete<{ success: boolean }>(`/pr/events/${eventId}/photos/${photoId}`),
// PR SMM каналы и подписчики
getSMMChannels: () => apiClient.get<SMMChannel[]>('/pr/smm-channels'),
getSMMChannelsSummary: () => apiClient.get<SMMChannelsSummary>('/pr/smm-channels/summary'),
createSMMChannel: (payload: { name: string; type: 'tg' | 'vk' | 'wa' | 'other'; url?: string; sortOrder?: number }) =>
apiClient.post<SMMChannel>('/pr/smm-channels', payload),
updateSMMChannel: (id: number, payload: Partial<Pick<SMMChannel, 'name' | 'type' | 'url' | 'sortOrder'>>) =>
apiClient.put<SMMChannel>(`/pr/smm-channels/${id}`, payload),
deleteSMMChannel: (id: number) => apiClient.delete<{ success: boolean }>(`/pr/smm-channels/${id}`),
createSMMChannelSnapshot: (id: number, payload: { subscribersCount: number; note?: string; recordedAt?: string }) =>
apiClient.post<SMMSubscriberSnapshot>(`/pr/smm-channels/${id}/snapshot`, payload),
// PR Привлечение
getAttractionActions: (params?: { channelId?: number; actionType?: string; from?: string; to?: string; limit?: number }) => {
const q = new URLSearchParams();
if (params?.channelId != null) q.append('channelId', String(params.channelId));
if (params?.actionType) q.append('actionType', params.actionType);
if (params?.from) q.append('from', params.from);
if (params?.to) q.append('to', params.to);
if (params?.limit != null) q.append('limit', String(params.limit));
const query = q.toString();
return apiClient.get<AttractionAction[]>(`/pr/attraction-actions${query ? `?${query}` : ''}`);
},
createAttractionAction: (payload: { title: string; description?: string; channelId?: number; actionType: AttractionAction['actionType']; actionDate?: string; newSubscribersAttributed?: number; eventId?: number }) =>
apiClient.post<AttractionAction>('/pr/attraction-actions', payload),
updateAttractionAction: (id: number, payload: Partial<Pick<AttractionAction, 'title' | 'description' | 'channelId' | 'actionType' | 'actionDate' | 'newSubscribersAttributed' | 'eventId'>>) =>
apiClient.put<AttractionAction>(`/pr/attraction-actions/${id}`, payload),
deleteAttractionAction: (id: number) => apiClient.delete<{ success: boolean }>(`/pr/attraction-actions/${id}`),
// PR График публикации (темы постов)
getPostTopics: (params?: { month?: string; status?: string; limit?: number }) => {
const q = new URLSearchParams();
if (params?.month) q.append('month', params.month);
if (params?.status) q.append('status', params.status);
if (params?.limit != null) q.append('limit', String(params.limit));
const query = q.toString();
return apiClient.get<PostTopic[]>(`/pr/post-topics${query ? `?${query}` : ''}`);
},
createPostTopic: (payload: { title: string; description?: string; scheduledDate: string; month: string; status?: PostTopic['status']; createdBy?: string }) =>
apiClient.post<PostTopic>('/pr/post-topics', payload),
updatePostTopic: (id: number, payload: Partial<Pick<PostTopic, 'title' | 'description' | 'scheduledDate' | 'status'>>) =>
apiClient.put<PostTopic>(`/pr/post-topics/${id}`, payload),
approvePostTopic: (id: number, payload?: { approvedBy?: string }) =>
apiClient.post<PostTopic>(`/pr/post-topics/${id}/approve`, payload || {}),
rejectPostTopic: (id: number, payload: { rejectionReason?: string; approvedBy?: string }) =>
apiClient.post<PostTopic>(`/pr/post-topics/${id}/reject`, payload),
deletePostTopic: (id: number) => apiClient.delete<{ success: boolean }>(`/pr/post-topics/${id}`),
// PR Отложенные посты
getScheduledPosts: (params?: { status?: string; topicId?: number; from?: string; to?: string; limit?: number }) => {
const q = new URLSearchParams();
if (params?.status) q.append('status', params.status);
if (params?.topicId != null) q.append('topicId', String(params.topicId));
if (params?.from) q.append('from', params.from);
if (params?.to) q.append('to', params.to);
if (params?.limit != null) q.append('limit', String(params.limit));
const query = q.toString();
return apiClient.get<ScheduledPost[]>(`/pr/scheduled-posts${query ? `?${query}` : ''}`);
},
createScheduledPost: (payload: { title: string; content: string; channelIds?: number[]; scheduledAt: string; status?: ScheduledPost['status']; topicId?: number; createdBy?: string; image?: File }) => {
const formData = new FormData();
formData.append('title', payload.title);
formData.append('content', payload.content);
formData.append('channelIds', JSON.stringify(payload.channelIds || []));
// Преобразуем datetime-local в ISO формат
const scheduledAtISO = new Date(payload.scheduledAt).toISOString();
formData.append('scheduledAt', scheduledAtISO);
if (payload.status) formData.append('status', payload.status);
if (payload.topicId) formData.append('topicId', String(payload.topicId));
if (payload.createdBy) formData.append('createdBy', payload.createdBy);
if (payload.image) formData.append('image', payload.image);
// Не устанавливаем Content-Type вручную - браузер сделает это сам с boundary
return apiClient.post<ScheduledPost>('/pr/scheduled-posts', formData);
},
updateScheduledPost: (id: number, payload: Partial<Pick<ScheduledPost, 'title' | 'content' | 'channelIds' | 'scheduledAt' | 'status' | 'topicId'>> & { image?: File; removeImage?: boolean }) => {
const formData = new FormData();
if (payload.title !== undefined) formData.append('title', payload.title);
if (payload.content !== undefined) formData.append('content', payload.content);
if (payload.channelIds !== undefined) formData.append('channelIds', JSON.stringify(payload.channelIds));
if (payload.scheduledAt !== undefined) {
// Преобразуем datetime-local в ISO формат
const scheduledAtISO = new Date(payload.scheduledAt).toISOString();
formData.append('scheduledAt', scheduledAtISO);
}
if (payload.status !== undefined) formData.append('status', payload.status);
if (payload.topicId !== undefined) formData.append('topicId', payload.topicId !== null ? String(payload.topicId) : '');
if (payload.image) formData.append('image', payload.image);
if (payload.removeImage) formData.append('removeImage', 'true');
// Не устанавливаем Content-Type вручную - браузер сделает это сам с boundary
return apiClient.put<ScheduledPost>(`/pr/scheduled-posts/${id}`, formData);
},
approveScheduledPost: (id: number, payload?: { approvedBy?: string }) =>
apiClient.post<ScheduledPost>(`/pr/scheduled-posts/${id}/approve`, payload || {}),
rejectScheduledPost: (id: number, payload: { rejectionReason?: string; approvedBy?: string }) =>
apiClient.post<ScheduledPost>(`/pr/scheduled-posts/${id}/reject`, payload),
sendScheduledPostToEdit: (id: number, payload: { editedContent: string; approvedBy?: string }) =>
apiClient.post<ScheduledPost>(`/pr/scheduled-posts/${id}/send-to-edit`, payload),
deleteScheduledPost: (id: number) => apiClient.delete<{ success: boolean }>(`/pr/scheduled-posts/${id}`),
};