Files
mkd/components/admin/IntegrationsSection.tsx
2026-02-04 00:17:04 +05:00

435 lines
18 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState } from 'react';
import { backendApi } from '../../services/apiClient';
import { Building, ParsingSettings } from '../../types';
import { settingsService, DomaAISettings } from '../../services/settingsService';
import { apiClient } from '../../services/apiClient';
import { DomaPendingMappings } from '../applications/DomaPendingMappings';
import { Loader2, Plug, Link2 } from 'lucide-react';
export const IntegrationsSection: React.FC = () => {
const [domaApiUrl, setDomaApiUrl] = useState('');
const [domaToken, setDomaToken] = useState('');
const [domaSaving, setDomaSaving] = useState(false);
const [domaSyncing, setDomaSyncing] = useState(false);
const [dadataEnabled, setDadataEnabled] = useState(true);
const [dadataApiKey, setDadataApiKey] = useState('');
const [dadataSecret, setDadataSecret] = useState('');
const [dadataLoading, setDadataLoading] = useState(true);
const [dadataSaving, setDadataSaving] = useState(false);
const [parsingSettings, setParsingSettings] = useState<ParsingSettings[]>([]);
const [parsingLoading, setParsingLoading] = useState(false);
useEffect(() => {
const loadDoma = async () => {
try {
const data = await backendApi.getDomaSettings();
setDomaApiUrl(data.apiUrl || '');
setDomaToken(data.token || '');
} catch {
const doma = settingsService.getDomaAISettings();
if (doma) {
setDomaApiUrl(doma.apiUrl || '');
setDomaToken(doma.token || '');
}
}
};
loadDoma();
loadDadata();
loadParsing();
}, []);
const loadParsing = async () => {
setParsingLoading(true);
try {
const data = await apiClient.get<any[]>('/pr/parsing-settings');
const processed = data.map((item: any) => ({
...item,
buildingId: item.buildingId ?? (item.settings?.building_id ?? null),
}));
setParsingSettings(processed);
} catch {
setParsingSettings([]);
} finally {
setParsingLoading(false);
}
};
const updateParsing = async (source: 'yandex_maps' | '2gis', updates: Partial<ParsingSettings>) => {
try {
const apiUpdates: Record<string, unknown> = {};
if (updates.enabled !== undefined) apiUpdates.enabled = updates.enabled;
if (updates.urlTemplate !== undefined) apiUpdates.url_template = updates.urlTemplate;
if (updates.apiKey !== undefined) apiUpdates.api_key = updates.apiKey;
if (updates.parsingIntervalHours !== undefined) apiUpdates.parsing_interval_hours = updates.parsingIntervalHours;
if (updates.settings !== undefined) apiUpdates.settings = updates.settings;
await apiClient.put(`/pr/parsing-settings/${source}`, apiUpdates);
await loadParsing();
alert('Настройки парсинга сохранены');
} catch (e: any) {
alert(e?.message || 'Ошибка сохранения');
}
};
const testParsing = async (source: 'yandex_maps' | '2gis') => {
try {
const result = await apiClient.post<{ parsed?: number; found?: number }>(`/pr/parsing-settings/${source}/test`, {});
alert(`Тестовый запрос завершён. Найдено отзывов: ${result?.found ?? result?.parsed ?? 0}`);
} catch (e: any) {
alert(`Ошибка: ${e?.message || 'Неизвестная ошибка'}`);
}
};
const loadDadata = async () => {
setDadataLoading(true);
try {
const data = await backendApi.getDadataSettings();
setDadataEnabled(data.enabled !== false);
setDadataApiKey(data.apiKey || '');
setDadataSecret(data.secret || '');
} catch {
setDadataApiKey('');
setDadataSecret('');
} finally {
setDadataLoading(false);
}
};
const saveDoma = async () => {
if (!domaApiUrl.trim()) {
alert('Укажите адрес API');
return;
}
setDomaSaving(true);
try {
const apiUrl = domaApiUrl.trim();
const token = domaToken.trim() || '';
await backendApi.saveDomaSettings({ apiUrl, token });
const settings: DomaAISettings = { apiUrl, token: token || undefined };
settingsService.saveDomaAISettings(settings);
const { domaGraphQLClient } = await import('../../services/domaGraphQLClient');
domaGraphQLClient.updateSettings();
domaGraphQLClient.setToken(settings.token || '');
alert('Настройки Дома.АИ сохранены');
} catch (e: any) {
alert(e?.message || 'Ошибка сохранения');
} finally {
setDomaSaving(false);
}
};
const runDomaSync = async () => {
setDomaSyncing(true);
try {
const result = await backendApi.domaSyncNow();
alert(`Синхронизация завершена. Загружено заявок: ${result?.synced ?? 0}`);
} catch (e: any) {
if (e?.message?.includes('401') || (e?.message && e.message.indexOf('авторизац') !== -1)) {
alert('Требуется авторизация. Войдите в систему и нажмите «Синхронизировать» снова.');
} else {
alert(e?.message || 'Ошибка синхронизации');
}
} finally {
setDomaSyncing(false);
}
};
const saveDadata = async () => {
setDadataSaving(true);
try {
await backendApi.saveDadataSettings({
enabled: dadataEnabled,
apiKey: dadataApiKey.trim(),
secret: dadataSecret.trim(),
});
alert('Настройки DaData сохранены');
} catch (e: any) {
alert(e?.message || 'Ошибка сохранения');
} finally {
setDadataSaving(false);
}
};
return (
<div className="space-y-8">
<h3 className="text-lg font-bold text-slate-800">Интеграции</h3>
<p className="text-sm text-slate-500">
Doma AI, DaData, парсинг отзывов с карт и другие внешние сервисы. Сопоставление адресов и исполнителей Doma с вашей базой в блоке Дома.АИ ниже.
</p>
{/* Doma AI */}
<div className="rounded-xl border border-slate-200 bg-slate-50 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-primary-100 flex items-center justify-center">
<span className="text-primary-600 font-bold text-sm">Д.АИ</span>
</div>
<div>
<h4 className="font-bold text-slate-800">Дома.АИ</h4>
<p className="text-xs text-slate-500">Заявки, API и сопоставление адресов/исполнителей</p>
</div>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs font-bold text-slate-700 mb-1">Адрес API</label>
<input
type="text"
value={domaApiUrl}
onChange={(e) => setDomaApiUrl(e.target.value)}
placeholder="https://your-domain.doma.ai/admin/api"
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-700 mb-1">Токен доступа</label>
<textarea
value={domaToken}
onChange={(e) => setDomaToken(e.target.value)}
placeholder="Токен Doma AI"
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm min-h-[60px]"
/>
</div>
<div className="flex gap-2">
<button
onClick={saveDoma}
disabled={domaSaving}
className="px-4 py-2 bg-primary-600 text-white text-sm font-bold rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{domaSaving ? <Loader2 className="w-4 h-4 animate-spin inline" /> : 'Сохранить'}
</button>
<button
onClick={runDomaSync}
disabled={domaSyncing || !domaApiUrl.trim()}
className="px-4 py-2 bg-slate-600 text-white text-sm font-bold rounded-lg hover:bg-slate-700 disabled:opacity-50"
>
{domaSyncing ? <Loader2 className="w-4 h-4 animate-spin inline" /> : 'Синхронизировать сейчас'}
</button>
</div>
</div>
{/* Сопоставления Doma AI: ожидающие и существующие */}
<div className="mt-6 pt-6 border-t border-slate-200">
<h5 className="text-sm font-bold text-slate-700 mb-3">Сопоставления Doma AI</h5>
<p className="text-xs text-slate-500 mb-4">
Связь адресов и исполнителей из Doma AI с домами и сотрудниками вашей базы. Ожидающие после синхронизации; существующие можно править или удалить в разделе «Очистка данных».
</p>
<DomaPendingMappings />
</div>
</div>
{/* DaData */}
<div className="rounded-xl border border-slate-200 bg-slate-50 p-6">
<div className="flex items-center gap-3 mb-4">
<Plug className="w-10 h-10 text-slate-500" />
<div>
<h4 className="font-bold text-slate-800">DaData</h4>
<p className="text-xs text-slate-500">Проверка контрагентов по ИНН в юр. отделе</p>
</div>
</div>
{dadataLoading ? (
<div className="flex items-center gap-2 text-slate-500 text-sm"><Loader2 className="w-4 h-4 animate-spin" /> Загрузка...</div>
) : (
<div className="space-y-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={dadataEnabled}
onChange={(e) => setDadataEnabled(e.target.checked)}
className="w-4 h-4 text-primary-600 rounded"
/>
<span className="text-sm text-slate-700">Включено</span>
</label>
<div>
<label className="block text-xs font-bold text-slate-700 mb-1">API Key (Token)</label>
<input
type="password"
value={dadataApiKey}
onChange={(e) => setDadataApiKey(e.target.value)}
placeholder="Токен DaData"
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-700 mb-1">Secret</label>
<input
type="password"
value={dadataSecret}
onChange={(e) => setDadataSecret(e.target.value)}
placeholder="Секретный ключ DaData"
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
/>
</div>
<button
onClick={saveDadata}
disabled={dadataSaving}
className="px-4 py-2 bg-primary-600 text-white text-sm font-bold rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{dadataSaving ? <Loader2 className="w-4 h-4 animate-spin inline" /> : 'Сохранить'}
</button>
</div>
)}
</div>
{/* Подключение API отзывов */}
<div className="rounded-xl border border-slate-200 bg-slate-50 p-6">
<h4 className="font-bold text-slate-800 mb-2">Подключение API отзывов</h4>
<p className="text-xs text-slate-500 mb-4">
Для 2ГИС укажите API ключ и URL страницы организации. Для Яндекса отзывы через API недоступны.
</p>
{parsingLoading ? (
<div className="flex items-center gap-2 text-slate-500 text-sm"><Loader2 className="w-4 h-4 animate-spin" /> Загрузка...</div>
) : (
<div className="space-y-4">
{parsingSettings.find((s) => s.source === 'yandex_maps') && (
<ParsingCard
setting={parsingSettings.find((s) => s.source === 'yandex_maps')!}
sourceName="Яндекс Карты"
onUpdate={(u) => updateParsing('yandex_maps', u)}
onTest={() => testParsing('yandex_maps')}
/>
)}
{parsingSettings.find((s) => s.source === '2gis') && (
<ParsingCard
setting={parsingSettings.find((s) => s.source === '2gis')!}
sourceName="2ГИС"
onUpdate={(u) => updateParsing('2gis', u)}
onTest={() => testParsing('2gis')}
/>
)}
{parsingSettings.length === 0 && (
<p className="text-sm text-slate-500">Настройки парсинга не заданы</p>
)}
</div>
)}
</div>
{/* Другие интеграции */}
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50/50 p-6">
<div className="flex items-center gap-3 text-slate-500">
<Link2 className="w-8 h-8" />
<div>
<h4 className="font-bold text-slate-600">Другие интеграции</h4>
<p className="text-xs">Здесь можно добавить новые интеграции по мере необходимости</p>
</div>
</div>
</div>
</div>
);
};
function ParsingCard({
setting,
sourceName,
onUpdate,
onTest,
}: {
setting: ParsingSettings;
sourceName: string;
onUpdate: (u: Partial<ParsingSettings>) => void;
onTest: () => void;
}) {
const [enabled, setEnabled] = useState(setting.enabled);
const [urlTemplate, setUrlTemplate] = useState(setting.urlTemplate || '');
const [apiKey, setApiKey] = useState(setting.apiKey || '');
const [interval, setInterval] = useState(setting.parsingIntervalHours ?? 24);
const [buildingId, setBuildingId] = useState(setting.buildingId || '');
const [buildings, setBuildings] = useState<Building[]>([]);
const [isLoadingBuildings, setIsLoadingBuildings] = useState(true);
useEffect(() => {
const load = async () => {
try {
setIsLoadingBuildings(true);
const data = await backendApi.getBuildings();
setBuildings(data);
} catch {
setBuildings([]);
} finally {
setIsLoadingBuildings(false);
}
};
load();
}, []);
const handleSave = () => {
onUpdate({
enabled,
urlTemplate: urlTemplate || undefined,
apiKey: apiKey || undefined,
parsingIntervalHours: interval,
settings: { ...(setting.settings || {}), building_id: buildingId || null },
});
};
return (
<div className="border border-slate-200 rounded-lg p-4 bg-white">
<div className="flex justify-between items-center mb-3">
<h5 className="font-bold text-slate-800 text-sm">{sourceName}</h5>
<label className="flex items-center gap-2 cursor-pointer text-xs">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="w-3.5 h-3.5 text-primary-600 rounded"
/>
Включено
</label>
</div>
<div className="space-y-2 text-sm">
<div>
<label className="block text-xs text-slate-600 mb-0.5">URL шаблон</label>
<input
type="text"
value={urlTemplate}
onChange={(e) => setUrlTemplate(e.target.value)}
placeholder="https://2gis.ru/ufa/firm/2393065583658695/tab/reviews"
className="w-full px-2 py-1.5 border border-slate-200 rounded text-sm"
/>
</div>
<div>
<label className="block text-xs text-slate-600 mb-0.5">API ключ</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="w-full px-2 py-1.5 border border-slate-200 rounded text-sm"
/>
</div>
<div>
<label className="block text-xs text-slate-600 mb-0.5">Интервал (часов)</label>
<input
type="number"
value={interval}
onChange={(e) => setInterval(parseInt(e.target.value, 10) || 24)}
min={1}
className="w-full px-2 py-1.5 border border-slate-200 rounded text-sm"
/>
</div>
<div>
<label className="block text-xs text-slate-600 mb-0.5">Дом (опционально)</label>
{isLoadingBuildings ? (
<div className="w-full px-2 py-1.5 border border-slate-200 rounded text-sm text-slate-400">Загрузка домов...</div>
) : (
<select
value={buildingId}
onChange={(e) => setBuildingId(e.target.value)}
className="w-full px-2 py-1.5 border border-slate-200 rounded text-sm"
>
<option value="">Выберите дом (опционально)</option>
{buildings.map((b) => (
<option key={b.id} value={b.id}>{b.passport?.address || b.id}</option>
))}
</select>
)}
</div>
</div>
<div className="flex gap-2 mt-3">
<button onClick={handleSave} className="px-3 py-1.5 bg-primary-600 text-white text-xs font-bold rounded-lg hover:bg-primary-700">
Сохранить
</button>
<button onClick={onTest} className="px-3 py-1.5 bg-slate-100 text-slate-700 text-xs font-medium rounded-lg hover:bg-slate-200">
Тест
</button>
</div>
</div>
);
}