435 lines
18 KiB
TypeScript
435 lines
18 KiB
TypeScript
|
|
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>
|
|||
|
|
);
|
|||
|
|
}
|