Files
mkd/services/apiClient.ts

1456 lines
72 KiB
TypeScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
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}`),
};