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 { 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 { 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(); 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(path: string, options: RequestInit = {}): Promise { 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 = {}; // Не устанавливаем 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: (path: string) => request(path, { method: 'GET' }), post: (path: string, body?: unknown, options?: RequestInit) => request(path, { method: 'POST', body: body instanceof FormData ? body : (body ? JSON.stringify(body) : undefined), ...options, }), put: (path: string, body?: unknown, options?: RequestInit) => request(path, { method: 'PUT', body: body instanceof FormData ? body : (body ? JSON.stringify(body) : undefined), ...options, }), patch: (path: string, body?: unknown) => request(path, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined, }), delete: (path: string) => request(path, { method: 'DELETE' }), }; export interface NotificationItem { id: number; type: string; title: string; body: string | null; entityType: string | null; entityId: string | null; payload: Record | null; readAt: string | null; createdAt: string; } // Примеры специализированных вызовов — чтобы было проще настраивать бэкенд. // Вы можете использовать их или удалить, если не нужны. export const backendApi = { // GET /districts -> District[] getDistricts: () => apiClient.get('/districts'), // POST /districts -> создать участок createDistrict: (payload: { name: string; managerName: string; inventory?: BuildingInventoryItem[] }) => apiClient.post('/districts', payload), // PUT /districts/:id -> обновить участок (включая inventory) updateDistrict: (id: string, payload: Partial) => apiClient.put(`/districts/${id}`, payload), // DELETE /districts/:id -> удалить участок deleteDistrict: (id: string) => apiClient.delete<{ success: boolean; message: string }>(`/districts/${id}`), // Справочник должностей getPositions: () => apiClient.get('/positions'), createPosition: (payload: { name: string; isManagerial?: boolean; sortOrder?: number }) => apiClient.post('/positions', payload), updatePosition: (id: string, payload: Partial>) => apiClient.put(`/positions/${id}`, payload), deletePosition: (id: string) => apiClient.delete(`/positions/${id}`), // GET /buildings -> Building[] getBuildings: () => apiClient.get('/buildings'), // GET /buildings/:id -> один дом с accounts (для выбора жителей) getBuilding: (id: string) => apiClient.get(`/buildings/${id}`), // POST /buildings -> создать дом (ожидает полный объект Building) createBuilding: (building: Building) => apiClient.post('/buildings', building), // PUT /buildings/:id -> обновить дом (паспорт и др. поля) updateBuilding: (building: Building) => apiClient.put(`/buildings/${building.id}`, building), // DELETE /buildings/:id -> удалить дом deleteBuilding: (id: string) => apiClient.delete<{ success: boolean; message: string }>(`/buildings/${id}`), // GET /applications -> DomaApplication[] getApplications: () => apiClient.get('/applications'), // GET /applications/:id -> одна заявка (карточка) getApplication: (id: number) => apiClient.get(`/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(`/applications/${id}`, payload), // GET /applications/:id/history -> история изменений getApplicationHistory: (id: number) => apiClient.get(`/applications/${id}/history`), // GET /applications/:id/comments -> комментарии (?type=internal|resident) getApplicationComments: (id: number, type?: 'internal' | 'resident') => apiClient.get(`/applications/${id}/comments${type ? `?type=${type}` : ''}`), // POST /applications/:id/comments -> добавить комментарий (internal — без жителя) addApplicationComment: (id: number, payload: { text: string; type?: 'internal' | 'resident'; authorName?: string }) => apiClient.post(`/applications/${id}/comments`, payload), // POST /applications -> создание заявки вручную (диспетчерская) createApplication: (payload: CreateApplicationPayload) => apiClient.post('/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(`/outages${q ? `?${q}` : ''}`); }, getOutage: (id: number) => apiClient.get(`/outages/${id}`), createOutage: (payload: import('../types').CreateOutagePayload) => apiClient.post('/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(`/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(`/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(`/company-news${q ? `?${q}` : ''}`); }, getCompanyNewsById: (id: number) => apiClient.get(`/company-news/${id}`), createCompanyNews: (payload: { title: string; body?: string | null; status?: CompanyNews['status']; notifyDepartments?: Department[]; notifyEmployeeIds?: string[] }) => apiClient.post('/company-news', payload), updateCompanyNews: (id: number, payload: { title?: string; body?: string | null; status?: CompanyNews['status']; notifyDepartments?: Department[]; notifyEmployeeIds?: string[] }) => apiClient.put(`/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('/auth/me'), updateProfile: (data: Partial) => apiClient.put('/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('/auth/preferences'), updatePreferences: (prefs: Partial) => apiClient.put('/auth/preferences', prefs), // ========= ПАНЕЛЬ УПРАВЛЕНИЯ: ПОРТАЛЬНЫЕ ПОЛЬЗОВАТЕЛИ ========= getPortalUserByLogin: (login: string) => apiClient.get(`/portal-users/me?login=${encodeURIComponent(login)}`), getPortalUsers: () => apiClient.get('/admin/portal-users'), createPortalUser: (payload: { employeeId: string; login: string; email?: string; role?: string; password?: string }) => apiClient.post('/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(`/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>('/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>('/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('/admin/permission-templates'), createPermissionTemplate: (payload: { name: string; description?: string; permissions: string[]; scope?: 'all' | 'own_district'; forPosition?: string | null; suggestedRole?: string | null }) => apiClient.post('/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(`/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(`/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(`/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(`/training/programs${query ? `?${query}` : ''}`); }, deleteTrainingProgram: (id: number) => apiClient.delete<{ success?: boolean; message?: string }>(`/training/programs/${id}`), // Финансы: категории и статьи расходов (для очистки данных) getExpenseCategories: () => apiClient.get('/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(`/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(`/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(`/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(`/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(`/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 }; inventory: { totalItems: number; lowStockCount: number }; documents: { total: number; incoming: number; outgoing: number; byStatus: Record }; orders: { total: number; byStatus: Record }; 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) => apiClient.post(`/buildings/${buildingId}/accounts`, account), // PUT /buildings/:id/accounts/:accountId -> обновление лицевого счета updateAccount: (buildingId: string, account: PersonalAccount) => apiClient.put(`/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('/employees'), getEmployeesList: () => apiClient.get>('/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(`/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('/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(`/legal/pre-trial-work${query ? `?${query}` : ''}`); }, // POST /legal/pre-trial-work -> создать досудебную работу createPreTrialWork: (work: { debtorId: number; assignedTo?: string; notes?: string; }) => apiClient.post('/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(`/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(`/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(`/legal/pre-trial-work/${workId}/promised-payment`, payment), // PUT /legal/pre-trial-work/:id/transfer-to-court -> передать в суд transferToCourt: (workId: number, courtCaseId?: string) => apiClient.put(`/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(`/legal/promised-payments/${paymentId}/mark-paid`, data), // ========= ПРОВЕРКА КОНТРАГЕНТОВ ========= // POST /legal/check-counterparty -> проверить контрагента по ИНН через DaData checkCounterparty: (inn: string) => apiClient.post('/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(`/legal/counterparties${query ? `?${query}` : ''}`); }, // GET /legal/counterparties/:id -> получить детальный отчет о проверке getCounterpartyReport: (id: number) => apiClient.get(`/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(`/legal/contracts${query ? `?${query}` : ''}`); }, // POST /legal/contracts -> создать договор createLegalContract: (contract: Partial & { number: string; type: string; counterparty: string; amount: number; startDate: string; endDate: string; manager: string }) => apiClient.post('/legal/contracts', contract), // PUT /legal/contracts/:id -> обновить договор updateLegalContract: (id: string, updates: Partial) => apiClient.put(`/legal/contracts/${id}`, updates), // GET /legal/contracts/:id -> получить договор getLegalContract: (id: string) => apiClient.get(`/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(`/legal/court-cases${query ? `?${query}` : ''}`); }, // POST /legal/court-cases -> создать судебное дело createLegalCourtCase: (courtCase: Partial & { caseNumber: string; type: string; role: string; subject: string; amount: number }) => apiClient.post('/legal/court-cases', courtCase), // PUT /legal/court-cases/:id -> обновить судебное дело updateLegalCourtCase: (id: string, updates: Partial) => apiClient.put(`/legal/court-cases/${id}`, updates), // GET /legal/court-cases/:id -> получить судебное дело getLegalCourtCase: (id: string) => apiClient.get(`/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(`/development/pipeline${query ? `?${query}` : ''}`); }, createDevelopmentPipeline: (item: Omit & { 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('/development/pipeline', payload); }, updateDevelopmentPipeline: (id: string, updates: Partial) => { const payload: Record = {}; 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(`/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(`/development/oss${query ? `?${query}` : ''}`); }, createDevelopmentOSS: (session: Omit & { 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('/development/oss', payload); }, updateDevelopmentOSS: (id: string, updates: Partial & { agenda_items?: string[]; agendaItems?: string[] }) => { const payload: Record = {}; 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(`/development/oss/${id}`, payload); }, updateOSSQuorum: (id: string, quorumCurrent: number) => apiClient.put(`/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 }) => apiClient.post(`/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 }[]) => apiClient.post<{ inserted: number; updated: number; total: number }>(`/development/oss/${id}/ballots/bulk`, { ballots }), getOSSRegistry: (id: string) => apiClient.get(`/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(`/development/audits${query ? `?${query}` : ''}`); }, getDevelopmentAudit: (id: string) => apiClient.get(`/development/audits/${id}`), createDevelopmentAudit: (audit: Omit & { 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('/development/audits', payload); }, updateDevelopmentAudit: (id: string, updates: Partial & { inspection_data?: Record; inspectionData?: Record }) => { const payload: Record = {}; 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(`/development/audits/${id}`, Object.keys(payload).length ? payload : updates); }, getAuditDefectList: (id: string) => apiClient.get(`/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(`/development/marketing${query ? `?${query}` : ''}`); }, createDevelopmentMarketing: (activity: Omit & { 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('/development/marketing', payload); }, updateDevelopmentMarketing: (id: string, updates: Partial & { 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(`/development/marketing/${id}`, payload); }, updateMarketingMetrics: (id: string, metrics: { activists_count?: number; meetings_held?: number; ads_distributed?: number }) => apiClient.put(`/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(`/development/locations${query ? `?${query}` : ''}`); }, createDevelopmentLocation: (location: { building_id?: string; address: string; latitude?: number; longitude?: number; status: string; competitor_name?: string }) => apiClient.post('/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(`/pr/events${query ? `?${query}` : ''}`); }, getPREvent: (id: string | number) => apiClient.get(`/pr/events/${id}`), createPREvent: (payload: Partial & { title: string; date: string; type: PREvent['type']; category: PREvent['category']; assignedEmployeeIds?: string[] }) => apiClient.post('/pr/events', payload), updatePREvent: (id: string | number, payload: Partial & { assignedEmployeeIds?: string[] }) => apiClient.put(`/pr/events/${id}`, payload), deletePREvent: (id: string | number) => apiClient.delete<{ success: boolean }>(`/pr/events/${id}`), getPREventPhotos: (eventId: string | number) => apiClient.get(`/pr/events/${eventId}/photos`), uploadPREventPhoto: (eventId: string | number, formData: FormData) => apiClient.post(`/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('/pr/smm-channels'), getSMMChannelsSummary: () => apiClient.get('/pr/smm-channels/summary'), createSMMChannel: (payload: { name: string; type: 'tg' | 'vk' | 'wa' | 'other'; url?: string; sortOrder?: number }) => apiClient.post('/pr/smm-channels', payload), updateSMMChannel: (id: number, payload: Partial>) => apiClient.put(`/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(`/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(`/pr/attraction-actions${query ? `?${query}` : ''}`); }, createAttractionAction: (payload: { title: string; description?: string; channelId?: number; actionType: AttractionAction['actionType']; actionDate?: string; newSubscribersAttributed?: number; eventId?: number }) => apiClient.post('/pr/attraction-actions', payload), updateAttractionAction: (id: number, payload: Partial>) => apiClient.put(`/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(`/pr/post-topics${query ? `?${query}` : ''}`); }, createPostTopic: (payload: { title: string; description?: string; scheduledDate: string; month: string; status?: PostTopic['status']; createdBy?: string }) => apiClient.post('/pr/post-topics', payload), updatePostTopic: (id: number, payload: Partial>) => apiClient.put(`/pr/post-topics/${id}`, payload), approvePostTopic: (id: number, payload?: { approvedBy?: string }) => apiClient.post(`/pr/post-topics/${id}/approve`, payload || {}), rejectPostTopic: (id: number, payload: { rejectionReason?: string; approvedBy?: string }) => apiClient.post(`/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(`/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('/pr/scheduled-posts', formData); }, updateScheduledPost: (id: number, payload: Partial> & { 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(`/pr/scheduled-posts/${id}`, formData); }, approveScheduledPost: (id: number, payload?: { approvedBy?: string }) => apiClient.post(`/pr/scheduled-posts/${id}/approve`, payload || {}), rejectScheduledPost: (id: number, payload: { rejectionReason?: string; approvedBy?: string }) => apiClient.post(`/pr/scheduled-posts/${id}/reject`, payload), sendScheduledPostToEdit: (id: number, payload: { editedContent: string; approvedBy?: string }) => apiClient.post(`/pr/scheduled-posts/${id}/send-to-edit`, payload), deleteScheduledPost: (id: number) => apiClient.delete<{ success: boolean }>(`/pr/scheduled-posts/${id}`), };