Initial commit MKD fixes
This commit is contained in:
434
components/admin/IntegrationsSection.tsx
Executable file
434
components/admin/IntegrationsSection.tsx
Executable file
@@ -0,0 +1,434 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user