Initial commit MKD fixes
This commit is contained in:
1455
services/apiClient.ts
Executable file
1455
services/apiClient.ts
Executable file
File diff suppressed because it is too large
Load Diff
206
services/connectionService.ts
Executable file
206
services/connectionService.ts
Executable file
@@ -0,0 +1,206 @@
|
||||
// Сервис управления подключением к серверу
|
||||
export type ConnectionStatus = 'connected' | 'connecting' | 'disconnected';
|
||||
|
||||
type ConnectionStatusCallback = (status: ConnectionStatus) => void;
|
||||
|
||||
class ConnectionService {
|
||||
private status: ConnectionStatus = 'disconnected';
|
||||
private listeners: Set<ConnectionStatusCallback> = new Set();
|
||||
private reconnectInterval: number | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = Infinity; // Бесконечные попытки
|
||||
private reconnectDelay = 3000; // 3 секунды между попытками
|
||||
private checkInterval: number | null = null;
|
||||
private apiBaseUrl: string;
|
||||
|
||||
constructor() {
|
||||
this.apiBaseUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
|
||||
// Если API не настроен, сразу устанавливаем статус connected
|
||||
if (!this.apiBaseUrl) {
|
||||
this.setStatus('connected');
|
||||
return;
|
||||
}
|
||||
|
||||
this.startConnectionCheck();
|
||||
this.initializeConnection();
|
||||
}
|
||||
|
||||
// Инициализация подключения
|
||||
private async initializeConnection() {
|
||||
if (!this.apiBaseUrl) {
|
||||
// Если API не настроен, считаем что подключение не требуется (используется localStorage)
|
||||
this.setStatus('connected');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setStatus('connecting');
|
||||
await this.checkConnection();
|
||||
}
|
||||
|
||||
// Проверка подключения
|
||||
private async checkConnection(): Promise<boolean> {
|
||||
if (!this.apiBaseUrl) {
|
||||
this.setStatus('connected');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 секунды таймаут
|
||||
|
||||
// Пробуем несколько endpoints для проверки подключения
|
||||
const endpoints = ['/health', '/districts', '/buildings'];
|
||||
let connected = false;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const response = await fetch(`${this.apiBaseUrl}${endpoint}`, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Если получили ответ (даже с ошибкой 404, 403 и т.д.), сервер доступен
|
||||
// 404 означает, что сервер работает, но endpoint не найден - это нормально
|
||||
if (response.status < 500) {
|
||||
connected = true;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
lastError = e instanceof Error ? e : new Error(String(e));
|
||||
// Пробуем следующий endpoint
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (connected) {
|
||||
this.setStatus('connected');
|
||||
this.reconnectAttempts = 0;
|
||||
this.stopReconnect();
|
||||
return true;
|
||||
} else {
|
||||
// Проверяем, не была ли это ошибка сети (CORS, таймаут и т.д.)
|
||||
const isNetworkError = lastError && (
|
||||
lastError.name === 'AbortError' ||
|
||||
lastError.message.includes('Failed to fetch') ||
|
||||
lastError.message.includes('NetworkError') ||
|
||||
lastError.message.includes('CORS')
|
||||
);
|
||||
|
||||
if (isNetworkError) {
|
||||
this.setStatus('disconnected');
|
||||
this.startReconnect();
|
||||
return false;
|
||||
} else {
|
||||
// Если это не ошибка сети, возможно сервер работает, но endpoint недоступен
|
||||
// В этом случае считаем что подключение есть
|
||||
this.setStatus('connected');
|
||||
this.stopReconnect();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Общая ошибка - считаем что нет подключения
|
||||
this.setStatus('disconnected');
|
||||
this.startReconnect();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Установка статуса и уведомление слушателей
|
||||
private setStatus(newStatus: ConnectionStatus) {
|
||||
if (this.status !== newStatus) {
|
||||
this.status = newStatus;
|
||||
this.listeners.forEach(callback => callback(newStatus));
|
||||
}
|
||||
}
|
||||
|
||||
// Начало процесса переподключения
|
||||
private startReconnect() {
|
||||
if (this.reconnectInterval) {
|
||||
return; // Уже переподключаемся
|
||||
}
|
||||
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
return; // Достигнут лимит попыток
|
||||
}
|
||||
|
||||
this.reconnectInterval = window.setInterval(async () => {
|
||||
this.reconnectAttempts++;
|
||||
this.setStatus('connecting');
|
||||
|
||||
const connected = await this.checkConnection();
|
||||
|
||||
if (connected) {
|
||||
this.stopReconnect();
|
||||
}
|
||||
}, this.reconnectDelay);
|
||||
}
|
||||
|
||||
// Остановка процесса переподключения
|
||||
private stopReconnect() {
|
||||
if (this.reconnectInterval) {
|
||||
clearInterval(this.reconnectInterval);
|
||||
this.reconnectInterval = null;
|
||||
}
|
||||
this.reconnectAttempts = 0;
|
||||
}
|
||||
|
||||
// Периодическая проверка подключения
|
||||
private startConnectionCheck() {
|
||||
// Проверяем каждые 10 секунд
|
||||
this.checkInterval = window.setInterval(async () => {
|
||||
if (this.status === 'connected') {
|
||||
await this.checkConnection();
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Получить текущий статус
|
||||
getStatus(): ConnectionStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
// Подписаться на изменения статуса
|
||||
subscribe(callback: ConnectionStatusCallback): () => void {
|
||||
this.listeners.add(callback);
|
||||
// Сразу вызываем callback с текущим статусом
|
||||
callback(this.status);
|
||||
|
||||
// Возвращаем функцию отписки
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
// Принудительная проверка подключения
|
||||
async forceCheck(): Promise<boolean> {
|
||||
if (!this.apiBaseUrl) {
|
||||
// Если API не настроен, считаем что подключение не требуется
|
||||
this.setStatus('connected');
|
||||
return true;
|
||||
}
|
||||
this.setStatus('connecting');
|
||||
return await this.checkConnection();
|
||||
}
|
||||
|
||||
// Очистка ресурсов
|
||||
destroy() {
|
||||
this.stopReconnect();
|
||||
if (this.checkInterval) {
|
||||
clearInterval(this.checkInterval);
|
||||
this.checkInterval = null;
|
||||
}
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Создаём единственный экземпляр сервиса
|
||||
export const connectionService = new ConnectionService();
|
||||
626
services/domaGraphQLClient.ts
Executable file
626
services/domaGraphQLClient.ts
Executable file
@@ -0,0 +1,626 @@
|
||||
/**
|
||||
* GraphQL клиент для работы с Doma AI API
|
||||
* Документация: https://developers.doma.ai/ru/docs/api/about
|
||||
*/
|
||||
|
||||
import { settingsService, DomaAISettings } from './settingsService';
|
||||
import { getAuthToken } from './apiClient';
|
||||
|
||||
export interface DomaGraphQLResponse<T> {
|
||||
data?: T;
|
||||
errors?: Array<{
|
||||
message: string;
|
||||
extensions?: any;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface DomaAuthResponse {
|
||||
authenticateUserWithPassword: {
|
||||
token: string;
|
||||
item: {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface DomaTicket {
|
||||
id: string;
|
||||
number: string;
|
||||
status: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
details?: string;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deadline?: string;
|
||||
property?: {
|
||||
id: string;
|
||||
address: string;
|
||||
unitName?: string;
|
||||
};
|
||||
client?: {
|
||||
id: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
};
|
||||
assignee?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
category?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DomaTicketsResponse {
|
||||
objs: DomaTicket[];
|
||||
meta?: {
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
|
||||
class DomaGraphQLClient {
|
||||
private apiUrl: string | null = null;
|
||||
private token: string | null = null;
|
||||
private tokenStorageKey = 'doma_ai_token';
|
||||
|
||||
constructor() {
|
||||
// Восстанавливаем токен из localStorage, если есть
|
||||
const storedToken = localStorage.getItem(this.tokenStorageKey);
|
||||
if (storedToken) {
|
||||
this.token = storedToken;
|
||||
}
|
||||
|
||||
// Настраиваем URL бэкенда, через который проксируем запросы в Doma AI
|
||||
this.updateApiUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет URL API до нашего backend-прокси, а не напрямую до Doma AI
|
||||
*/
|
||||
private updateApiUrl(): void {
|
||||
const backendBaseUrl = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
if (backendBaseUrl) {
|
||||
// Все запросы к Doma AI теперь идут через backend-прокси
|
||||
this.apiUrl = `${backendBaseUrl.replace(/\/$/, '')}/doma/graphql`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.apiUrl = null;
|
||||
console.warn(
|
||||
'[DomaGraphQLClient] VITE_API_BASE_URL не задан. ' +
|
||||
'Настройте URL бэкенда в переменных окружения фронтенда.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Устанавливает настройки API из настроек интеграций
|
||||
*/
|
||||
updateSettings(): void {
|
||||
this.updateApiUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Аутентификация пользователя через email и пароль
|
||||
* Если параметры не переданы, пытается получить из настроек
|
||||
*/
|
||||
async authenticate(email?: string, password?: string): Promise<string> {
|
||||
// Если параметры не переданы, пытаемся получить из настроек
|
||||
if (!email || !password) {
|
||||
const settings = settingsService.getDomaAISettings();
|
||||
if (settings?.email && settings?.password) {
|
||||
email = settings.email;
|
||||
password = settings.password;
|
||||
} else {
|
||||
throw new Error('Учетные данные не указаны. Укажите email и пароль в настройках интеграций.');
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем URL перед авторизацией
|
||||
this.updateApiUrl();
|
||||
const mutation = `
|
||||
mutation AuthenticateUser($email: String!, $password: String!) {
|
||||
authenticateUserWithPassword(email: $email, password: $password) {
|
||||
token
|
||||
item {
|
||||
id
|
||||
name
|
||||
email
|
||||
phone
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await this.request<DomaAuthResponse>(mutation, {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (response.errors) {
|
||||
throw new Error(response.errors[0]?.message || 'Ошибка аутентификации');
|
||||
}
|
||||
|
||||
if (response.data?.authenticateUserWithPassword?.token) {
|
||||
this.token = response.data.authenticateUserWithPassword.token;
|
||||
localStorage.setItem(this.tokenStorageKey, this.token);
|
||||
return this.token;
|
||||
}
|
||||
|
||||
throw new Error('Не удалось получить токен авторизации');
|
||||
}
|
||||
|
||||
/**
|
||||
* Аутентификация через телефон и пароль
|
||||
* Если параметры не переданы, пытается получить из настроек
|
||||
*/
|
||||
async authenticateWithPhone(phone?: string, password?: string): Promise<string> {
|
||||
// Если параметры не переданы, пытаемся получить из настроек
|
||||
if (!phone || !password) {
|
||||
const settings = settingsService.getDomaAISettings();
|
||||
if (settings?.phone && settings?.password) {
|
||||
phone = settings.phone;
|
||||
password = settings.password;
|
||||
} else {
|
||||
throw new Error('Учетные данные не указаны. Укажите телефон и пароль в настройках интеграций.');
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем URL перед авторизацией
|
||||
this.updateApiUrl();
|
||||
const mutation = `
|
||||
mutation AuthenticateUserWithPhone($phone: String!, $password: String!) {
|
||||
authenticateUserWithPhoneAndPassword(phone: $phone, password: $password) {
|
||||
token
|
||||
item {
|
||||
id
|
||||
name
|
||||
email
|
||||
phone
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface PhoneAuthResponse {
|
||||
authenticateUserWithPhoneAndPassword: {
|
||||
token: string;
|
||||
item: {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const response = await this.request<PhoneAuthResponse>(mutation, {
|
||||
phone,
|
||||
password,
|
||||
});
|
||||
|
||||
if (response.errors) {
|
||||
throw new Error(response.errors[0]?.message || 'Ошибка аутентификации');
|
||||
}
|
||||
|
||||
if (response.data?.authenticateUserWithPhoneAndPassword?.token) {
|
||||
this.token = response.data.authenticateUserWithPhoneAndPassword.token;
|
||||
localStorage.setItem(this.tokenStorageKey, this.token);
|
||||
return this.token;
|
||||
}
|
||||
|
||||
throw new Error('Не удалось получить токен авторизации');
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка текущего пользователя
|
||||
*/
|
||||
async getAuthenticatedUser() {
|
||||
const query = `
|
||||
query {
|
||||
authenticatedUser {
|
||||
id
|
||||
name
|
||||
email
|
||||
phone
|
||||
type
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await this.request(query);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение списка заявок (tickets)
|
||||
* В Doma AI заявки могут называться Ticket, ServiceRequest или аналогично
|
||||
*/
|
||||
async getTickets(filters?: {
|
||||
status?: string;
|
||||
assignee?: string;
|
||||
property?: string;
|
||||
limit?: number;
|
||||
skip?: number;
|
||||
}): Promise<DomaTicket[]> {
|
||||
// Пробуем разные возможные названия типов заявок в Doma AI
|
||||
// В зависимости от вашей схемы это может быть Ticket, ServiceRequest, Request и т.д.
|
||||
|
||||
const query = `
|
||||
query GetTickets($where: TicketWhereInput, $first: Int, $skip: Int) {
|
||||
objs: tickets(where: $where, first: $first, skip: $skip, orderBy: createdAt_DESC) {
|
||||
id
|
||||
number
|
||||
status {
|
||||
id
|
||||
name
|
||||
type
|
||||
}
|
||||
details
|
||||
description
|
||||
createdAt
|
||||
updatedAt
|
||||
deadline
|
||||
property {
|
||||
id
|
||||
address
|
||||
unitName
|
||||
}
|
||||
client {
|
||||
id
|
||||
name
|
||||
phone
|
||||
}
|
||||
assignee {
|
||||
id
|
||||
name
|
||||
}
|
||||
category {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const variables: any = {
|
||||
first: filters?.limit || 100,
|
||||
skip: filters?.skip || 0,
|
||||
};
|
||||
|
||||
if (filters) {
|
||||
variables.where = {};
|
||||
if (filters.status) {
|
||||
variables.where.status = { id: filters.status };
|
||||
}
|
||||
if (filters.assignee) {
|
||||
variables.where.assignee = { id: filters.assignee };
|
||||
}
|
||||
if (filters.property) {
|
||||
variables.where.property = { id: filters.property };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.request<{ objs: DomaTicket[] }>(query, variables);
|
||||
|
||||
if (response.errors) {
|
||||
console.warn('[DomaGraphQLClient] Ошибка при получении заявок:', response.errors);
|
||||
// Пробуем альтернативный запрос
|
||||
return await this.getTicketsAlternative(filters);
|
||||
}
|
||||
|
||||
return response.data?.objs || [];
|
||||
} catch (error) {
|
||||
console.error('[DomaGraphQLClient] Ошибка при запросе заявок:', error);
|
||||
// Пробуем альтернативный запрос
|
||||
return await this.getTicketsAlternative(filters);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Альтернативный запрос заявок (если основная схема не работает)
|
||||
*/
|
||||
private async getTicketsAlternative(filters?: any): Promise<DomaTicket[]> {
|
||||
// Пробуем запрос через ServiceRequest или другие возможные типы
|
||||
const alternativeQueries = [
|
||||
// Вариант 1: ServiceRequest
|
||||
`
|
||||
query GetServiceRequests($where: ServiceRequestWhereInput, $first: Int, $skip: Int) {
|
||||
objs: serviceRequests(where: $where, first: $first, skip: $skip, orderBy: createdAt_DESC) {
|
||||
id
|
||||
number
|
||||
status {
|
||||
id
|
||||
name
|
||||
type
|
||||
}
|
||||
details
|
||||
description
|
||||
createdAt
|
||||
updatedAt
|
||||
deadline
|
||||
property {
|
||||
id
|
||||
address
|
||||
unitName
|
||||
}
|
||||
client {
|
||||
id
|
||||
name
|
||||
phone
|
||||
}
|
||||
assignee {
|
||||
id
|
||||
name
|
||||
}
|
||||
category {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
// Вариант 2: Request
|
||||
`
|
||||
query GetRequests($where: RequestWhereInput, $first: Int, $skip: Int) {
|
||||
objs: requests(where: $where, first: $first, skip: $skip, orderBy: createdAt_DESC) {
|
||||
id
|
||||
number
|
||||
status {
|
||||
id
|
||||
name
|
||||
type
|
||||
}
|
||||
details
|
||||
description
|
||||
createdAt
|
||||
updatedAt
|
||||
deadline
|
||||
property {
|
||||
id
|
||||
address
|
||||
unitName
|
||||
}
|
||||
client {
|
||||
id
|
||||
name
|
||||
phone
|
||||
}
|
||||
assignee {
|
||||
id
|
||||
name
|
||||
}
|
||||
category {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
for (const query of alternativeQueries) {
|
||||
try {
|
||||
const variables: any = {
|
||||
first: filters?.limit || 100,
|
||||
skip: filters?.skip || 0,
|
||||
};
|
||||
|
||||
if (filters) {
|
||||
variables.where = {};
|
||||
if (filters.status) {
|
||||
variables.where.status = { id: filters.status };
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.request<{ objs: DomaTicket[] }>(query, variables);
|
||||
|
||||
if (response.data?.objs) {
|
||||
return response.data.objs;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[DomaGraphQLClient] Альтернативный запрос не сработал:', error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Базовый метод для выполнения GraphQL запросов
|
||||
*/
|
||||
private async request<T>(
|
||||
query: string,
|
||||
variables?: Record<string, any>
|
||||
): Promise<DomaGraphQLResponse<T>> {
|
||||
if (!this.apiUrl) {
|
||||
throw new Error('VITE_DOMA_AI_API_URL не настроен. Укажите URL вашего инстанса Doma AI.');
|
||||
}
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const portalToken = getAuthToken();
|
||||
if (portalToken) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${portalToken}`;
|
||||
}
|
||||
|
||||
// Важно: авторизационный токен к Doma AI больше не отправляем из браузера напрямую.
|
||||
// Вместо этого передаём его в config.body на наш backend-прокси.
|
||||
|
||||
// Читаем актуальные настройки интеграции
|
||||
const settings = settingsService.getDomaAISettings();
|
||||
|
||||
const config = {
|
||||
apiUrl: settings?.apiUrl,
|
||||
token: settings?.token || this.token || null,
|
||||
};
|
||||
|
||||
try {
|
||||
const bodyPayload = {
|
||||
query,
|
||||
variables: variables || {},
|
||||
config,
|
||||
};
|
||||
|
||||
console.log('[DomaGraphQLClient] Запрос к Doma AI', {
|
||||
url: this.apiUrl,
|
||||
hasToken: !!this.token,
|
||||
// Лучше не логировать полный запрос, чтобы не засорять консоль,
|
||||
// показываем только первые символы
|
||||
queryPreview: query.slice(0, 100),
|
||||
});
|
||||
|
||||
const response = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(bodyPayload),
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
|
||||
console.log('[DomaGraphQLClient] Ответ Doma AI', {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
length: rawText.length,
|
||||
preview: rawText.slice(0, 300),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
if (!rawText) {
|
||||
// Пустой ответ от Doma AI
|
||||
return {} as DomaGraphQLResponse<T>;
|
||||
}
|
||||
|
||||
const result: DomaGraphQLResponse<T> = JSON.parse(rawText);
|
||||
|
||||
// Если получили ошибку авторизации, очищаем токен
|
||||
if (result.errors?.some(e => e.message.includes('auth') || e.message.includes('unauthorized'))) {
|
||||
this.token = null;
|
||||
localStorage.removeItem(this.tokenStorageKey);
|
||||
throw new Error('Требуется повторная авторизация');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[DomaGraphQLClient] Ошибка запроса:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Установка токена вручную (если токен получен извне)
|
||||
*/
|
||||
setToken(token: string): void {
|
||||
this.token = token;
|
||||
localStorage.setItem(this.tokenStorageKey, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистка токена (выход)
|
||||
*/
|
||||
clearToken(): void {
|
||||
this.token = null;
|
||||
localStorage.removeItem(this.tokenStorageKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка наличия токена
|
||||
*/
|
||||
hasToken(): boolean {
|
||||
return !!this.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление заявки (ticket) в Doma AI
|
||||
* @param ticketId - ID заявки в Doma AI (UUID)
|
||||
* @param data - Данные для обновления
|
||||
*/
|
||||
async updateTicket(
|
||||
ticketId: string,
|
||||
data: {
|
||||
status?: { id?: string; type?: string };
|
||||
statusReason?: string;
|
||||
deferredUntil?: string;
|
||||
lastCommentWithResidentTypeAt?: string;
|
||||
}
|
||||
): Promise<{ id: string; number: string }> {
|
||||
const mutation = `
|
||||
mutation UpdateTicket($id: ID!, $data: TicketUpdateInput!) {
|
||||
obj: updateTicket(id: $id, data: $data) {
|
||||
id
|
||||
number
|
||||
status {
|
||||
id
|
||||
name
|
||||
type
|
||||
}
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const variables: any = {
|
||||
id: ticketId,
|
||||
data: {},
|
||||
};
|
||||
|
||||
// Маппинг локальных статусов в статусы Doma.AI
|
||||
const statusTypeMap: Record<string, string> = {
|
||||
new: 'new_or_reopened',
|
||||
in_progress: 'processing',
|
||||
deferred: 'deferred',
|
||||
done: 'completed',
|
||||
canceled: 'canceled',
|
||||
};
|
||||
|
||||
if (data.status?.type) {
|
||||
const domaStatusType = statusTypeMap[data.status.type] || data.status.type;
|
||||
variables.data.status = { type: domaStatusType };
|
||||
} else if (data.status?.id) {
|
||||
variables.data.status = { id: data.status.id };
|
||||
}
|
||||
|
||||
if (data.statusReason) {
|
||||
variables.data.statusReason = data.statusReason;
|
||||
}
|
||||
|
||||
if (data.deferredUntil) {
|
||||
variables.data.deferredUntil = data.deferredUntil;
|
||||
}
|
||||
|
||||
if (data.lastCommentWithResidentTypeAt) {
|
||||
variables.data.lastCommentWithResidentTypeAt = data.lastCommentWithResidentTypeAt;
|
||||
}
|
||||
|
||||
// Устанавливаем дату обновления статуса
|
||||
variables.data.statusUpdatedAt = new Date().toISOString();
|
||||
|
||||
const response = await this.request<{ obj: { id: string; number: string } }>(mutation, variables);
|
||||
|
||||
if (response.errors) {
|
||||
throw new Error(response.errors[0]?.message || 'Ошибка при обновлении заявки');
|
||||
}
|
||||
|
||||
if (!response.data?.obj) {
|
||||
throw new Error('Не удалось обновить заявку');
|
||||
}
|
||||
|
||||
return response.data.obj;
|
||||
}
|
||||
}
|
||||
|
||||
export const domaGraphQLClient = new DomaGraphQLClient();
|
||||
190
services/domaService.ts
Executable file
190
services/domaService.ts
Executable file
@@ -0,0 +1,190 @@
|
||||
import { DomaApplication, DomaApplicationStatus } from '../types';
|
||||
import { backendApi } from './apiClient';
|
||||
import { domaGraphQLClient, DomaTicket } from './domaGraphQLClient';
|
||||
import { settingsService } from './settingsService';
|
||||
|
||||
/**
|
||||
* Сервис для работы с Doma AI API
|
||||
* Интегрирует GraphQL API Doma AI для получения заявок
|
||||
*/
|
||||
export const domaService = {
|
||||
/**
|
||||
* Инициализация подключения к Doma AI
|
||||
* Выполняет аутентификацию, если токен не сохранен
|
||||
* Использует настройки из localStorage или переменные окружения как fallback
|
||||
*/
|
||||
async initialize(): Promise<boolean> {
|
||||
// Обновляем настройки в клиенте
|
||||
domaGraphQLClient.updateSettings();
|
||||
|
||||
// Получаем настройки из localStorage
|
||||
const settings = settingsService.getDomaAISettings();
|
||||
|
||||
// Проверяем, настроен ли URL API (из настроек или переменных окружения)
|
||||
const apiUrl = settings?.apiUrl || import.meta.env.VITE_DOMA_AI_API_URL;
|
||||
if (!apiUrl) {
|
||||
console.warn(
|
||||
'[domaService] URL API Doma AI не настроен. ' +
|
||||
'Укажите URL в настройках интеграций или в переменных окружения.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Если в настройках уже есть токен, просто используем его (без health-check запроса)
|
||||
if (settings?.token) {
|
||||
domaGraphQLClient.setToken(settings.token);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Если токен уже есть в клиенте, считаем, что авторизация настроена
|
||||
if (domaGraphQLClient.hasToken()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Пытаемся получить учетные данные из настроек или переменных окружения
|
||||
const email = settings?.email || import.meta.env.VITE_DOMA_AI_EMAIL;
|
||||
const password = settings?.password || import.meta.env.VITE_DOMA_AI_PASSWORD;
|
||||
const phone = settings?.phone || import.meta.env.VITE_DOMA_AI_PHONE;
|
||||
|
||||
if (!email && !phone) {
|
||||
console.warn(
|
||||
'[domaService] Учетные данные Doma AI не настроены. ' +
|
||||
'Укажите email/телефон и пароль в настройках интеграций или в переменных окружения.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
console.warn('[domaService] Пароль Doma AI не настроен. Укажите пароль в настройках интеграций.');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (email && password) {
|
||||
await domaGraphQLClient.authenticate(email, password);
|
||||
} else if (phone && password) {
|
||||
await domaGraphQLClient.authenticateWithPhone(phone, password);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[domaService] Ошибка авторизации в Doma AI:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Преобразует заявку из формата Doma AI в формат приложения
|
||||
*/
|
||||
mapDomaTicketToApplication(ticket: DomaTicket): DomaApplication {
|
||||
// Маппинг статусов Doma AI в статусы приложения
|
||||
const statusMap: Record<string, DomaApplicationStatus> = {
|
||||
'new': 'new',
|
||||
'in_progress': 'in_progress',
|
||||
'inProgress': 'in_progress',
|
||||
'done': 'done',
|
||||
'completed': 'done',
|
||||
'canceled': 'canceled',
|
||||
'cancelled': 'canceled',
|
||||
};
|
||||
|
||||
const statusType = ticket.status?.type?.toLowerCase() || ticket.status?.name?.toLowerCase() || 'new';
|
||||
const mappedStatus = statusMap[statusType] || 'new';
|
||||
|
||||
return {
|
||||
id: parseInt(ticket.id) || Date.now(), // Если ID не число, используем timestamp
|
||||
number: ticket.number || ticket.id,
|
||||
status: mappedStatus,
|
||||
description: ticket.description || ticket.details || 'Без описания',
|
||||
address: ticket.property?.address || 'Адрес не указан',
|
||||
apartment: ticket.property?.unitName || '—',
|
||||
clientName: ticket.client?.name || 'Клиент не указан',
|
||||
createdAt: ticket.createdAt || new Date().toISOString(),
|
||||
deadlineAt: ticket.deadline || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // По умолчанию +7 дней
|
||||
performer: ticket.assignee ? { name: ticket.assignee.name } : undefined,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Получает список заявок из Doma AI
|
||||
* @param filters - Фильтры для запроса заявок
|
||||
* @returns Массив заявок в формате приложения
|
||||
*/
|
||||
async getApplications(filters?: {
|
||||
status?: DomaApplicationStatus;
|
||||
limit?: number;
|
||||
}): Promise<DomaApplication[]> {
|
||||
console.log('[domaService] Получение заявок из Doma AI...');
|
||||
|
||||
// Проверяем и инициализируем подключение, если нужно
|
||||
if (!domaGraphQLClient.hasToken()) {
|
||||
const initialized = await this.initialize();
|
||||
if (!initialized) {
|
||||
console.warn('[domaService] Не удалось подключиться к Doma AI, используем fallback');
|
||||
return this.getApplicationsFallback();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Преобразуем фильтры для Doma AI
|
||||
const domaFilters: any = {};
|
||||
if (filters?.status) {
|
||||
// Маппинг статусов обратно (упрощенный вариант)
|
||||
// В реальности нужно знать ID статусов в Doma AI
|
||||
domaFilters.status = filters.status;
|
||||
}
|
||||
|
||||
// Получаем заявки из Doma AI
|
||||
const tickets = await domaGraphQLClient.getTickets({
|
||||
...domaFilters,
|
||||
limit: filters?.limit || 100,
|
||||
});
|
||||
|
||||
// Преобразуем в формат приложения
|
||||
const applications = tickets.map(ticket => this.mapDomaTicketToApplication(ticket));
|
||||
|
||||
console.log(`[domaService] Получено ${applications.length} заявок из Doma AI`);
|
||||
return applications;
|
||||
} catch (error) {
|
||||
console.error('[domaService] Ошибка при получении заявок из Doma AI:', error);
|
||||
|
||||
// Fallback: пробуем получить из бэкенда
|
||||
return this.getApplicationsFallback();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fallback метод: получает заявки из бэкенда
|
||||
* Важно: демо-мок-данные больше не используются, чтобы не путать с реальной интеграцией
|
||||
*/
|
||||
async getApplicationsFallback(): Promise<DomaApplication[]> {
|
||||
try {
|
||||
console.log('[domaService] Попытка получить заявки из бэкенда...');
|
||||
const apps = await backendApi.getApplications();
|
||||
return apps;
|
||||
} catch (error) {
|
||||
console.warn('[domaService] Бэкенд недоступен, возвращаем пустой список заявок:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Обновление статуса заявки в Doma AI
|
||||
* @param ticketId - ID заявки в Doma AI
|
||||
* @param status - Новый статус
|
||||
*/
|
||||
async updateApplicationStatus(ticketId: string, status: DomaApplicationStatus): Promise<boolean> {
|
||||
if (!domaGraphQLClient.hasToken()) {
|
||||
const initialized = await this.initialize();
|
||||
if (!initialized) {
|
||||
throw new Error('Не удалось авторизоваться в Doma AI');
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Реализовать мутацию для обновления статуса заявки
|
||||
// Это зависит от схемы GraphQL в Doma AI
|
||||
console.warn('[domaService] Обновление статуса заявки в Doma AI пока не реализовано');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
55
services/geminiService.ts
Executable file
55
services/geminiService.ts
Executable file
@@ -0,0 +1,55 @@
|
||||
// Заглушки для функций AI (Gemini отключен)
|
||||
import { Building, ResidentFeedback, AIAnalysisResult, AnalyzedFeedback } from "../types";
|
||||
|
||||
export const generateBuildingAudit = async (building: Building): Promise<string> => {
|
||||
// Заглушка - функция AI отключена
|
||||
return `# Анализ дома ${building.passport.address}
|
||||
|
||||
## Общее состояние
|
||||
Дом в эксплуатации. Требуется ручной анализ данных.
|
||||
|
||||
## Финансовое состояние
|
||||
- Баланс: ${building.financials.balance.toLocaleString('ru-RU')} ₽
|
||||
- Собираемость: ${building.financials.collectionRate}%
|
||||
- Задолженность: ${building.financials.debt.toLocaleString('ru-RU')} ₽
|
||||
|
||||
*Примечание: Функция AI анализа отключена.*
|
||||
`;
|
||||
};
|
||||
|
||||
export const generateResidentFinancialSummary = async (building: Building): Promise<string> => {
|
||||
// Заглушка - функция AI отключена
|
||||
const balance = building.financials.balance;
|
||||
const collectionRate = building.financials.collectionRate;
|
||||
|
||||
return `Уважаемые жители дома ${building.passport.address}!
|
||||
|
||||
💰 Баланс дома: ${balance.toLocaleString('ru-RU')} ₽
|
||||
📊 Собираемость платежей: ${collectionRate}%
|
||||
|
||||
${collectionRate > 90 ? '✅ Благодарим за своевременную оплату!' : '⚠️ Просим своевременно оплачивать коммунальные услуги.'}
|
||||
|
||||
*Примечание: Функция AI генерации отключена. Текст сформирован автоматически.*
|
||||
`;
|
||||
};
|
||||
|
||||
export const analyzeResidentFeedback = async (feedback: ResidentFeedback[]): Promise<AIAnalysisResult | null> => {
|
||||
// Заглушка - функция AI отключена
|
||||
// Возвращаем базовый анализ без AI
|
||||
const analyzedFeedback: AnalyzedFeedback[] = feedback.map(f => ({
|
||||
...f,
|
||||
category: 'Другое',
|
||||
sentiment: f.rating >= 7 ? 'Positive' : f.rating <= 3 ? 'Negative' : 'Neutral'
|
||||
}));
|
||||
|
||||
const positiveCount = analyzedFeedback.filter(f => f.sentiment === 'Positive').length;
|
||||
const negativeCount = analyzedFeedback.filter(f => f.sentiment === 'Negative').length;
|
||||
|
||||
return {
|
||||
analyzedFeedback,
|
||||
summary: {
|
||||
positive: positiveCount > 0 ? [`Получено ${positiveCount} положительных отзывов`] : [],
|
||||
negative: negativeCount > 0 ? [`Получено ${negativeCount} отрицательных отзывов`] : []
|
||||
}
|
||||
};
|
||||
};
|
||||
141
services/offlineCacheService.ts
Executable file
141
services/offlineCacheService.ts
Executable file
@@ -0,0 +1,141 @@
|
||||
// Сервис кэширования данных для offline режима
|
||||
type CachedRequest = {
|
||||
id: string;
|
||||
method: string;
|
||||
url: string;
|
||||
body?: unknown;
|
||||
timestamp: number;
|
||||
retries: number;
|
||||
};
|
||||
|
||||
const CACHE_KEY = 'mkd_offline_cache';
|
||||
const MAX_RETRIES = 10;
|
||||
const MAX_CACHE_AGE = 7 * 24 * 60 * 60 * 1000; // 7 дней
|
||||
|
||||
class OfflineCacheService {
|
||||
// Сохранить запрос в кэш
|
||||
cacheRequest(method: string, url: string, body?: unknown): string {
|
||||
const cachedRequests = this.getCachedRequests();
|
||||
const id = `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const request: CachedRequest = {
|
||||
id,
|
||||
method,
|
||||
url,
|
||||
body,
|
||||
timestamp: Date.now(),
|
||||
retries: 0,
|
||||
};
|
||||
|
||||
cachedRequests.push(request);
|
||||
this.saveCachedRequests(cachedRequests);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
// Получить все закэшированные запросы
|
||||
getCachedRequests(): CachedRequest[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(CACHE_KEY);
|
||||
if (stored) {
|
||||
const requests = JSON.parse(stored) as CachedRequest[];
|
||||
// Фильтруем устаревшие запросы
|
||||
const now = Date.now();
|
||||
return requests.filter(req => now - req.timestamp < MAX_CACHE_AGE);
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
console.error('Failed to get cached requests', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Сохранить закэшированные запросы
|
||||
private saveCachedRequests(requests: CachedRequest[]) {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(requests));
|
||||
} catch (e) {
|
||||
console.error('Failed to save cached requests', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Удалить запрос из кэша
|
||||
removeRequest(id: string): void {
|
||||
const cachedRequests = this.getCachedRequests();
|
||||
const filtered = cachedRequests.filter(req => req.id !== id);
|
||||
this.saveCachedRequests(filtered);
|
||||
}
|
||||
|
||||
// Увеличить счётчик попыток для запроса
|
||||
incrementRetries(id: string): void {
|
||||
const cachedRequests = this.getCachedRequests();
|
||||
const updated = cachedRequests.map(req => {
|
||||
if (req.id === id) {
|
||||
return { ...req, retries: req.retries + 1 };
|
||||
}
|
||||
return req;
|
||||
});
|
||||
this.saveCachedRequests(updated);
|
||||
}
|
||||
|
||||
// Синхронизировать все закэшированные запросы
|
||||
async syncCachedRequests(apiBaseUrl: string): Promise<{ success: number; failed: number }> {
|
||||
const cachedRequests = this.getCachedRequests();
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const request of cachedRequests) {
|
||||
// Пропускаем запросы с превышенным лимитом попыток
|
||||
if (request.retries >= MAX_RETRIES) {
|
||||
this.removeRequest(request.id);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const fullUrl = request.url.startsWith('http')
|
||||
? request.url
|
||||
: `${apiBaseUrl}${request.url}`;
|
||||
|
||||
const options: RequestInit = {
|
||||
method: request.method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
if (request.body && (request.method === 'POST' || request.method === 'PUT')) {
|
||||
options.body = JSON.stringify(request.body);
|
||||
}
|
||||
|
||||
const response = await fetch(fullUrl, options);
|
||||
|
||||
if (response.ok) {
|
||||
this.removeRequest(request.id);
|
||||
success++;
|
||||
} else {
|
||||
this.incrementRetries(request.id);
|
||||
failed++;
|
||||
}
|
||||
} catch (error) {
|
||||
this.incrementRetries(request.id);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { success, failed };
|
||||
}
|
||||
|
||||
// Очистить весь кэш
|
||||
clearCache(): void {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
}
|
||||
|
||||
// Получить количество закэшированных запросов
|
||||
getCacheSize(): number {
|
||||
return this.getCachedRequests().length;
|
||||
}
|
||||
}
|
||||
|
||||
// Создаём единственный экземпляр сервиса
|
||||
export const offlineCacheService = new OfflineCacheService();
|
||||
77
services/settingsService.ts
Executable file
77
services/settingsService.ts
Executable file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Сервис для работы с настройками интеграций
|
||||
* Хранит настройки в localStorage
|
||||
*/
|
||||
|
||||
export interface DomaAISettings {
|
||||
apiUrl: string;
|
||||
/**
|
||||
* Токен доступа к Doma AI (предпочтительный способ авторизации)
|
||||
*/
|
||||
token?: string;
|
||||
/**
|
||||
* Резервные поля для авторизации по логину/паролю (fallback)
|
||||
*/
|
||||
email?: string;
|
||||
phone?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface IntegrationSettings {
|
||||
domaAI: DomaAISettings;
|
||||
}
|
||||
|
||||
const SETTINGS_KEY = 'mkd_integration_settings';
|
||||
|
||||
export const settingsService = {
|
||||
/**
|
||||
* Получить настройки интеграций
|
||||
*/
|
||||
getIntegrationSettings(): IntegrationSettings | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error('[settingsService] Ошибка при чтении настроек:', e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Сохранить настройки интеграций
|
||||
*/
|
||||
saveIntegrationSettings(settings: IntegrationSettings): void {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
} catch (e) {
|
||||
console.error('[settingsService] Ошибка при сохранении настроек:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить настройки Дома.АИ
|
||||
*/
|
||||
getDomaAISettings(): DomaAISettings | null {
|
||||
const settings = this.getIntegrationSettings();
|
||||
return settings?.domaAI || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Сохранить настройки Дома.АИ
|
||||
*/
|
||||
saveDomaAISettings(domaAISettings: DomaAISettings): void {
|
||||
const currentSettings = this.getIntegrationSettings() || { domaAI: domaAISettings };
|
||||
currentSettings.domaAI = domaAISettings;
|
||||
this.saveIntegrationSettings(currentSettings);
|
||||
},
|
||||
|
||||
/**
|
||||
* Очистить настройки интеграций
|
||||
*/
|
||||
clearIntegrationSettings(): void {
|
||||
localStorage.removeItem(SETTINGS_KEY);
|
||||
},
|
||||
};
|
||||
272
services/storageService.ts
Executable file
272
services/storageService.ts
Executable file
@@ -0,0 +1,272 @@
|
||||
|
||||
import { Building, District } from "../types";
|
||||
import { MOCK_BUILDINGS, MOCK_DISTRICTS } from "../constants";
|
||||
|
||||
const DB_KEY_BUILDINGS = 'mkd_buildings_v2';
|
||||
const DB_KEY_DISTRICTS = 'mkd_districts_v2';
|
||||
const DB_KEY_GLOBAL_PASSPORT_FIELDS = 'mkd_global_passport_fields';
|
||||
|
||||
export const storageService = {
|
||||
// --- DISTRICTS ---
|
||||
getDistricts: (): District[] => {
|
||||
try {
|
||||
const stored = localStorage.getItem(DB_KEY_DISTRICTS);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
saveDistrict: (district: District): void => {
|
||||
const all = storageService.getDistricts();
|
||||
const index = all.findIndex(d => d.id === district.id);
|
||||
let newAll;
|
||||
if (index >= 0) {
|
||||
newAll = [...all];
|
||||
newAll[index] = district;
|
||||
} else {
|
||||
newAll = [...all, district];
|
||||
}
|
||||
localStorage.setItem(DB_KEY_DISTRICTS, JSON.stringify(newAll));
|
||||
},
|
||||
|
||||
createDistrict: (data: Omit<District, 'id'>): District => {
|
||||
const newDistrict: District = {
|
||||
...data,
|
||||
id: `d-${Date.now()}`
|
||||
};
|
||||
storageService.saveDistrict(newDistrict);
|
||||
return newDistrict;
|
||||
},
|
||||
|
||||
deleteDistrict: (id: string): void => {
|
||||
const all = storageService.getDistricts();
|
||||
const newAll = all.filter(d => d.id !== id);
|
||||
localStorage.setItem(DB_KEY_DISTRICTS, JSON.stringify(newAll));
|
||||
},
|
||||
|
||||
// --- BUILDINGS ---
|
||||
getAllBuildings: (): Building[] => {
|
||||
try {
|
||||
const stored = localStorage.getItem(DB_KEY_BUILDINGS);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
getBuildingsByDistrict: (districtId: string): Building[] => {
|
||||
const all = storageService.getAllBuildings();
|
||||
return all.filter(b => b.districtId === districtId);
|
||||
},
|
||||
|
||||
getBuildingById: (id: string): Building | undefined => {
|
||||
const all = storageService.getAllBuildings();
|
||||
return all.find(b => b.id === id);
|
||||
},
|
||||
|
||||
saveBuildingData: (updatedBuilding: Building): void => {
|
||||
try {
|
||||
const all = storageService.getAllBuildings();
|
||||
const index = all.findIndex(b => b.id === updatedBuilding.id);
|
||||
const toSave = {
|
||||
...updatedBuilding,
|
||||
isDirty: true,
|
||||
lastSync: Date.now()
|
||||
};
|
||||
let newAll;
|
||||
if (index >= 0) {
|
||||
newAll = [...all];
|
||||
newAll[index] = toSave;
|
||||
} else {
|
||||
newAll = [...all, toSave];
|
||||
}
|
||||
localStorage.setItem(DB_KEY_BUILDINGS, JSON.stringify(newAll));
|
||||
} catch (e) {
|
||||
console.error("Failed to save to storage", e);
|
||||
}
|
||||
},
|
||||
|
||||
createBuilding: (data: Partial<Building> & { address: string, districtId: string }): Building => {
|
||||
const id = `b-${Date.now()}`;
|
||||
const newBuilding: Building = {
|
||||
id,
|
||||
districtId: data.districtId,
|
||||
imageUrl: data.imageUrl || 'https://picsum.photos/800/600',
|
||||
nps: 0,
|
||||
passport: {
|
||||
address: data.address,
|
||||
apartmentsCount: 0,
|
||||
general: {
|
||||
address: data.address,
|
||||
fiasCode: '',
|
||||
constructionYear: new Date().getFullYear(),
|
||||
commissionYear: new Date().getFullYear(),
|
||||
seriesType: 'Индивидуальный',
|
||||
floors: 1,
|
||||
undergroundFloors: 0,
|
||||
totalArea: 0,
|
||||
livingArea: 0,
|
||||
nonLivingArea: 0,
|
||||
commonArea: 0,
|
||||
cadastralNumberBuild: '',
|
||||
cadastralNumberLand: ''
|
||||
},
|
||||
construction: { foundationType: '', foundationMaterial: '', wallMaterial: '', floorMaterial: '', roofType: '', roofMaterial: '', roofArea: 0, facadeType: '', facadeInsulation: false, windowType: '' },
|
||||
engineering: { heatingType: '', heatingWiring: '', hasITP: false, waterSupplyMaterial: '', waterSupplyType: '', sewerMaterial: '', electricityEntries: 1, hasVRU: false, gasType: '', ventilationType: '' },
|
||||
odpu: { customFields: {} },
|
||||
meters: [],
|
||||
lifts: [],
|
||||
land: { area: 0, hasPlayground: false, hasSportsGround: false, hasParking: false, hasFencing: false, hasContainerSite: false },
|
||||
management: {
|
||||
contractDate: '',
|
||||
contractNumber: '',
|
||||
servicesList: [],
|
||||
tariffMaintenance: 0,
|
||||
reserveFund: 5.00, // Значение по умолчанию 5%
|
||||
serviceContracts: [] // Initialize empty array
|
||||
},
|
||||
...data.passport
|
||||
},
|
||||
staff: [],
|
||||
entrances: [],
|
||||
commonSections: [],
|
||||
accounts: [],
|
||||
financials: { balance: 0, debt: 0, collectionRate: 0, topDebtors: [], invoices: [] },
|
||||
requests: { new: 0, inProgress: 0, overdue: 0 },
|
||||
inspectionHistory: [],
|
||||
tasks: [],
|
||||
annualPlan: [],
|
||||
inventory: [],
|
||||
writeOffHistory: [],
|
||||
residents: [],
|
||||
reports: [],
|
||||
isDirty: true,
|
||||
...data
|
||||
};
|
||||
storageService.saveBuildingData(newBuilding);
|
||||
return newBuilding;
|
||||
},
|
||||
|
||||
deleteBuilding: (id: string): void => {
|
||||
const all = storageService.getAllBuildings();
|
||||
const newAll = all.filter(b => b.id !== id);
|
||||
localStorage.setItem(DB_KEY_BUILDINGS, JSON.stringify(newAll));
|
||||
},
|
||||
|
||||
syncWithServer: async (): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const all = storageService.getAllBuildings();
|
||||
const cleaned = all.map(b => ({ ...b, isDirty: false }));
|
||||
localStorage.setItem(DB_KEY_BUILDINGS, JSON.stringify(cleaned));
|
||||
resolve(true);
|
||||
}, 1500);
|
||||
});
|
||||
},
|
||||
clearLocal: () => {
|
||||
localStorage.removeItem(DB_KEY_BUILDINGS);
|
||||
localStorage.removeItem(DB_KEY_DISTRICTS);
|
||||
},
|
||||
|
||||
// --- GLOBAL PASSPORT FIELDS (для всех домов) ---
|
||||
getGlobalPassportFields: (): { [section: string]: { [fieldName: string]: { value: any; type: string; files?: string[] } } } => {
|
||||
try {
|
||||
const stored = localStorage.getItem(DB_KEY_GLOBAL_PASSPORT_FIELDS);
|
||||
if (stored) return JSON.parse(stored);
|
||||
return {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
saveGlobalPassportField: (section: 'general' | 'construction' | 'engineering' | 'odpu' | 'land' | 'management', fieldName: string, fieldType: 'text' | 'number' | 'checkbox'): void => {
|
||||
const globalFields = storageService.getGlobalPassportFields();
|
||||
if (!globalFields[section]) {
|
||||
globalFields[section] = {};
|
||||
}
|
||||
const defaultValue = fieldType === 'number' ? 0 : fieldType === 'checkbox' ? false : '';
|
||||
globalFields[section][fieldName] = {
|
||||
value: defaultValue,
|
||||
type: fieldType,
|
||||
files: []
|
||||
};
|
||||
localStorage.setItem(DB_KEY_GLOBAL_PASSPORT_FIELDS, JSON.stringify(globalFields));
|
||||
|
||||
// Применяем это поле ко всем домам
|
||||
const allBuildings = storageService.getAllBuildings();
|
||||
allBuildings.forEach(building => {
|
||||
const sectionData = building.passport[section] as any;
|
||||
const customFields = { ...(sectionData.customFields || {}) };
|
||||
if (!customFields[fieldName]) {
|
||||
customFields[fieldName] = {
|
||||
value: defaultValue,
|
||||
type: fieldType,
|
||||
files: []
|
||||
};
|
||||
const updatedSection = { ...sectionData, customFields };
|
||||
building.passport[section] = updatedSection as any;
|
||||
}
|
||||
});
|
||||
localStorage.setItem(DB_KEY_BUILDINGS, JSON.stringify(allBuildings));
|
||||
},
|
||||
|
||||
updateGlobalPassportField: (section: 'general' | 'construction' | 'engineering' | 'odpu' | 'land' | 'management', fieldName: string, value: any, files?: string[]): void => {
|
||||
const globalFields = storageService.getGlobalPassportFields();
|
||||
if (globalFields[section] && globalFields[section][fieldName]) {
|
||||
globalFields[section][fieldName].value = value;
|
||||
if (files !== undefined) {
|
||||
globalFields[section][fieldName].files = files;
|
||||
}
|
||||
localStorage.setItem(DB_KEY_GLOBAL_PASSPORT_FIELDS, JSON.stringify(globalFields));
|
||||
|
||||
// Обновляем это поле во всех домах
|
||||
const allBuildings = storageService.getAllBuildings();
|
||||
allBuildings.forEach(building => {
|
||||
const sectionData = building.passport[section] as any;
|
||||
const customFields = { ...(sectionData.customFields || {}) };
|
||||
if (customFields[fieldName]) {
|
||||
customFields[fieldName].value = value;
|
||||
if (files !== undefined) {
|
||||
customFields[fieldName].files = files;
|
||||
}
|
||||
const updatedSection = { ...sectionData, customFields };
|
||||
building.passport[section] = updatedSection as any;
|
||||
}
|
||||
});
|
||||
localStorage.setItem(DB_KEY_BUILDINGS, JSON.stringify(allBuildings));
|
||||
}
|
||||
},
|
||||
|
||||
deleteGlobalPassportField: (section: 'general' | 'construction' | 'engineering' | 'odpu' | 'land' | 'management', fieldName: string): void => {
|
||||
const globalFields = storageService.getGlobalPassportFields();
|
||||
if (globalFields[section] && globalFields[section][fieldName]) {
|
||||
delete globalFields[section][fieldName];
|
||||
if (Object.keys(globalFields[section]).length === 0) {
|
||||
delete globalFields[section];
|
||||
}
|
||||
localStorage.setItem(DB_KEY_GLOBAL_PASSPORT_FIELDS, JSON.stringify(globalFields));
|
||||
|
||||
// Удаляем это поле из всех домов
|
||||
const allBuildings = storageService.getAllBuildings();
|
||||
allBuildings.forEach(building => {
|
||||
const sectionData = building.passport[section] as any;
|
||||
const customFields = { ...(sectionData.customFields || {}) };
|
||||
if (customFields[fieldName]) {
|
||||
delete customFields[fieldName];
|
||||
const updatedSection = { ...sectionData, customFields };
|
||||
building.passport[section] = updatedSection as any;
|
||||
}
|
||||
});
|
||||
localStorage.setItem(DB_KEY_BUILDINGS, JSON.stringify(allBuildings));
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user