261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
|
|
import React, { useEffect, useMemo, useState } from 'react';
|
|||
|
|
import { backendApi } from '../../services/apiClient';
|
|||
|
|
import { Building, Employee } from '../../types';
|
|||
|
|
import { AlertTriangle, Check, Loader2, RefreshCw } from 'lucide-react';
|
|||
|
|
|
|||
|
|
type PendingItem = {
|
|||
|
|
id: number;
|
|||
|
|
domaValue: string;
|
|||
|
|
suggestedId?: string | null;
|
|||
|
|
suggestedName?: string | null;
|
|||
|
|
createdAt: string;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type PendingResponse = {
|
|||
|
|
success: boolean;
|
|||
|
|
data: {
|
|||
|
|
buildings: PendingItem[];
|
|||
|
|
employees: PendingItem[];
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
interface DomaPendingMappingsProps {
|
|||
|
|
onClose?: () => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const DomaPendingMappings: React.FC<DomaPendingMappingsProps> = ({ onClose }) => {
|
|||
|
|
const [pending, setPending] = useState<PendingResponse['data'] | null>(null);
|
|||
|
|
const [buildings, setBuildings] = useState<Building[]>([]);
|
|||
|
|
const [employees, setEmployees] = useState<Employee[]>([]);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [resolvingId, setResolvingId] = useState<number | null>(null);
|
|||
|
|
const [error, setError] = useState<string | null>(null);
|
|||
|
|
|
|||
|
|
const loadData = async () => {
|
|||
|
|
setLoading(true);
|
|||
|
|
setError(null);
|
|||
|
|
try {
|
|||
|
|
const [pendingRes, buildingsRes, employeesRes] = await Promise.all([
|
|||
|
|
backendApi.getDomaPendingMappings(),
|
|||
|
|
backendApi.getBuildings(),
|
|||
|
|
backendApi.getEmployees(),
|
|||
|
|
]);
|
|||
|
|
if (!pendingRes.success) {
|
|||
|
|
throw new Error('Ошибка при загрузке ожидающих сопоставлений');
|
|||
|
|
}
|
|||
|
|
setPending(pendingRes.data);
|
|||
|
|
setBuildings(buildingsRes);
|
|||
|
|
setEmployees(employeesRes);
|
|||
|
|
} catch (e: any) {
|
|||
|
|
console.error('[DomaPendingMappings] Ошибка загрузки данных:', e);
|
|||
|
|
setError(e?.message || 'Ошибка загрузки данных');
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
loadData();
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const buildingOptions = useMemo(
|
|||
|
|
() =>
|
|||
|
|
buildings.map((b) => ({
|
|||
|
|
id: b.id,
|
|||
|
|
label: b.passport?.address || 'Без адреса',
|
|||
|
|
})),
|
|||
|
|
[buildings]
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const employeeOptions = useMemo(
|
|||
|
|
() =>
|
|||
|
|
employees.map((e) => ({
|
|||
|
|
id: e.id,
|
|||
|
|
label: e.name,
|
|||
|
|
})),
|
|||
|
|
[employees]
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const handleResolve = async (item: PendingItem, type: 'building' | 'employee', targetId: string) => {
|
|||
|
|
if (!targetId) return;
|
|||
|
|
try {
|
|||
|
|
setResolvingId(item.id);
|
|||
|
|
await backendApi.resolveDomaPendingMapping(item.id, targetId);
|
|||
|
|
window.dispatchEvent(new CustomEvent('mkd-applications-changed'));
|
|||
|
|
await loadData();
|
|||
|
|
} catch (e: any) {
|
|||
|
|
console.error('[DomaPendingMappings] Ошибка при сопоставлении:', e);
|
|||
|
|
setError(e?.message || 'Ошибка при сопоставлении');
|
|||
|
|
} finally {
|
|||
|
|
setResolvingId(null);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const hasData =
|
|||
|
|
pending && (pending.buildings.length > 0 || pending.employees.length > 0);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="bg-white rounded-2xl shadow-lg border border-slate-200 p-4 md:p-6 space-y-4">
|
|||
|
|
<div className="flex items-center justify-between gap-2 mb-2">
|
|||
|
|
<div>
|
|||
|
|
<h3 className="text-base md:text-lg font-bold text-slate-800">
|
|||
|
|
Ожидающие сопоставления из Doma AI
|
|||
|
|
</h3>
|
|||
|
|
<p className="text-[11px] text-slate-500">
|
|||
|
|
Здесь вы вручную связываете адреса и исполнителей из Doma AI с домами и
|
|||
|
|
сотрудниками вашей базы. После этого синхронизация будет «чистой».
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<button
|
|||
|
|
onClick={loadData}
|
|||
|
|
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-xl text-[11px] font-bold border border-slate-200 text-slate-600 hover:bg-slate-50"
|
|||
|
|
>
|
|||
|
|
<RefreshCw className="w-3 h-3" /> Обновить
|
|||
|
|
</button>
|
|||
|
|
{onClose && (
|
|||
|
|
<button
|
|||
|
|
onClick={onClose}
|
|||
|
|
className="text-[11px] text-slate-400 hover:text-slate-600 px-2 py-1 rounded-lg border border-transparent hover:border-slate-200"
|
|||
|
|
>
|
|||
|
|
Закрыть
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{loading && (
|
|||
|
|
<div className="flex items-center justify-center py-10 text-slate-500 text-sm">
|
|||
|
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
|||
|
|
Загрузка ожидающих сопоставлений...
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{!loading && error && (
|
|||
|
|
<div className="flex items-center gap-2 text-sm text-red-600 bg-red-50 border border-red-100 rounded-xl px-3 py-2">
|
|||
|
|
<AlertTriangle className="w-4 h-4" />
|
|||
|
|
<span>{error}</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{!loading && !error && !hasData && (
|
|||
|
|
<div className="text-center py-8 text-sm text-slate-500">
|
|||
|
|
<p className="font-medium">Нет ожидающих сопоставлений</p>
|
|||
|
|
<p className="text-xs mt-1">
|
|||
|
|
Все адреса и исполнители из Doma AI уже сопоставлены с вашей базой.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{!loading && !error && hasData && pending && (
|
|||
|
|
<div className="space-y-6">
|
|||
|
|
{pending.buildings.length > 0 && (
|
|||
|
|
<section>
|
|||
|
|
<h4 className="text-xs font-black text-slate-500 uppercase tracking-[0.2em] mb-2">
|
|||
|
|
Адреса домов
|
|||
|
|
</h4>
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
{pending.buildings.map((item) => (
|
|||
|
|
<div
|
|||
|
|
key={`b-${item.id}`}
|
|||
|
|
className="flex flex-col md:flex-row md:items-center gap-2 border border-slate-200 rounded-xl px-3 py-2 bg-slate-50/60"
|
|||
|
|
>
|
|||
|
|
<div className="flex-1 min-w-0">
|
|||
|
|
<p className="text-sm font-medium text-slate-800 truncate">
|
|||
|
|
{item.domaValue}
|
|||
|
|
</p>
|
|||
|
|
<p className="text-[11px] text-slate-400">
|
|||
|
|
из Doma AI • {new Date(item.createdAt).toLocaleString()}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-2 md:min-w-[260px]">
|
|||
|
|
<select
|
|||
|
|
className="flex-1 text-xs border border-slate-200 rounded-xl px-2 py-1.5 bg-white"
|
|||
|
|
defaultValue=""
|
|||
|
|
onChange={(e) =>
|
|||
|
|
handleResolve(
|
|||
|
|
item,
|
|||
|
|
'building',
|
|||
|
|
e.target.value || ''
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
disabled={resolvingId === item.id}
|
|||
|
|
>
|
|||
|
|
<option value="">Выберите дом...</option>
|
|||
|
|
{buildingOptions.map((b) => (
|
|||
|
|
<option key={b.id} value={b.id}>
|
|||
|
|
{b.label}
|
|||
|
|
</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
{resolvingId === item.id && (
|
|||
|
|
<Loader2 className="w-4 h-4 text-primary-500 animate-spin" />
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{pending.employees.length > 0 && (
|
|||
|
|
<section>
|
|||
|
|
<h4 className="text-xs font-black text-slate-500 uppercase tracking-[0.2em] mb-2">
|
|||
|
|
Исполнители (сотрудники)
|
|||
|
|
</h4>
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
{pending.employees.map((item) => (
|
|||
|
|
<div
|
|||
|
|
key={`e-${item.id}`}
|
|||
|
|
className="flex flex-col md:flex-row md:items-center gap-2 border border-slate-200 rounded-xl px-3 py-2 bg-slate-50/60"
|
|||
|
|
>
|
|||
|
|
<div className="flex-1 min-w-0">
|
|||
|
|
<p className="text-sm font-medium text-slate-800 truncate">
|
|||
|
|
{item.domaValue}
|
|||
|
|
</p>
|
|||
|
|
<p className="text-[11px] text-slate-400">
|
|||
|
|
из Doma AI • {new Date(item.createdAt).toLocaleString()}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-2 md:min-w-[260px]">
|
|||
|
|
<select
|
|||
|
|
className="flex-1 text-xs border border-slate-200 rounded-xl px-2 py-1.5 bg-white"
|
|||
|
|
defaultValue=""
|
|||
|
|
onChange={(e) =>
|
|||
|
|
handleResolve(
|
|||
|
|
item,
|
|||
|
|
'employee',
|
|||
|
|
e.target.value || ''
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
disabled={resolvingId === item.id}
|
|||
|
|
>
|
|||
|
|
<option value="">Выберите сотрудника...</option>
|
|||
|
|
{employeeOptions.map((e) => (
|
|||
|
|
<option key={e.id} value={e.id}>
|
|||
|
|
{e.label}
|
|||
|
|
</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
{resolvingId === item.id && (
|
|||
|
|
<Loader2 className="w-4 h-4 text-primary-500 animate-spin" />
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<p className="text-[11px] text-slate-400 flex items-center gap-1">
|
|||
|
|
<Check className="w-3 h-3 text-emerald-500" />
|
|||
|
|
После сопоставления значение будет сохранено и в следующих синхронизациях
|
|||
|
|
дом/сотрудник будут подставляться автоматически.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|