Files
mkd/components/admin/IntegrationsSection.tsx

435 lines
18 KiB
TypeScript
Raw Permalink Normal View History

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