"use client"; import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Badge } from "@/components/ui/Badge"; import { apiFetch } from "@/lib/api"; import { cardStyle as cardStyleBase, td, th } from "@/lib/adminStyles"; const cardStyle = { ...cardStyleBase, marginTop: 16 }; // ── Типы ответа GET /api/v1/admin/etl/mapping-review ────────────────────────── interface MappingReviewRow { id: number; objective_complex_name: string | null; objective_project_id: number | null; objective_group: string | null; domrf_obj_id: number | null; match_method: string | null; match_score: number | null; is_reviewed: boolean; note: string | null; created_at: string | null; domrf_comm_name: string | null; domrf_dev_name: string | null; objective_developers: string[]; } interface MappingReviewResponse { rows: MappingReviewRow[]; total: number; limit: number; offset: number; only_unreviewed: boolean; } const LIMIT = 100; /** * Грубая нормализация ЯДРА имени для attention-подсветки (НЕ решение о * маппинге — только сигнал глазам). lower + убрать «жк», кавычки/скобки, * пунктуацию, схлопнуть пробелы. Если ядро objective НЕ содержится в ядре * domrf comm_name (и наоборот) — строка подсвечивается --warn-soft. */ function normCore(raw: string | null | undefined): string { if (!raw) return ""; return raw .toLowerCase() .replace(/[«»"'`“”()[]\[\]]/g, " ") .replace(/\bжк\b/g, " ") .replace(/\bжилой\s+комплекс\b/g, " ") .replace(/[^0-9a-zа-яё]+/gi, " ") .replace(/\s+/g, " ") .trim(); } /** true если ядра явно расходятся (ни одно не содержит другое) → внимание. */ function coresMismatch( objName: string | null, domrfName: string | null, ): boolean { const a = normCore(objName); const b = normCore(domrfName); // Нет одной из сторон для сравнения — не флагаем (нечего сравнивать). if (!a || !b) return false; if (a === b) return false; return !a.includes(b) && !b.includes(a); } function formatIso(s: string | null): string { if (!s) return "—"; return new Date(s).toLocaleDateString("ru-RU"); } function formatScore(v: number | null): string { if (v == null) return "—"; return v.toFixed(3); } export default function MappingReviewPage() { const [onlyUnreviewed, setOnlyUnreviewed] = useState(true); const [offset, setOffset] = useState(0); const [confirmReject, setConfirmReject] = useState( null, ); const queryClient = useQueryClient(); const listKey = ["mapping-review", onlyUnreviewed, offset] as const; const list = useQuery({ queryKey: listKey, queryFn: () => { const qs = new URLSearchParams({ only_unreviewed: String(onlyUnreviewed), limit: String(LIMIT), offset: String(offset), }); return apiFetch( `/api/v1/admin/etl/mapping-review?${qs.toString()}`, ); }, }); const invalidateList = () => queryClient.invalidateQueries({ queryKey: ["mapping-review"] }); const approve = useMutation({ mutationFn: (id: number) => apiFetch(`/api/v1/admin/etl/mapping-review/${id}/approve`, { method: "POST", }), onSuccess: () => void invalidateList(), }); const reject = useMutation({ mutationFn: (id: number) => apiFetch(`/api/v1/admin/etl/mapping-review/${id}/reject`, { method: "POST", }), onSuccess: () => { setConfirmReject(null); void invalidateList(); }, }); const rows = list.data?.rows ?? []; const total = list.data?.total ?? 0; const pageFrom = total === 0 ? 0 : offset + 1; const pageTo = Math.min(offset + LIMIT, total); const canPrev = offset > 0; const canNext = offset + LIMIT < total; // id строки, по которой сейчас идёт мутация — для локального disabled. const pendingApproveId = approve.isPending ? approve.variables : null; const pendingRejectId = reject.isPending ? reject.variables : null; return ( <>

Ревью авто-маппингов ЖК → DOM.РФ

Строки objective_complex_mapping, связавшие objective-проект с объектом DOM.РФ автоматически (fuzzy / core-dev / geo). Сомнительные — сверху (сорт с бэка). Подтвердить{" "} помечает маппинг проверенным. Отклонить удаляет строку — сделки этого комплекса исчезнут из §4.2. Строки, где ядро objective-имени не совпадает с ядром DOM.РФ, подсвечены жёлтым для внимания (эвристика, не приговор).

{/* Ошибка загрузки списка */} {list.isError && (
Ошибка загрузки списка: {(list.error as Error).message}
)} {/* Фильтр + пагинация header */}
{list.isLoading ? ( "Загрузка…" ) : ( <> {pageFrom}–{pageTo} из{" "} {total.toLocaleString("ru")} )}
{/* Ошибка мутаций (inline, не привязана к строке) */} {(approve.isError || reject.isError) && (
{approve.isError && `Не удалось подтвердить: ${(approve.error as Error).message}`} {reject.isError && `Не удалось отклонить: ${(reject.error as Error).message}`}
)} {/* Таблица */}
{!list.isLoading && rows.length === 0 ? (

{onlyUnreviewed ? "Непроверенных маппингов нет — всё отревьюено." : "Маппингов не найдено."}

) : (
{[ "Объектив (ЖК)", "Объектив-девелоперы", "DOM.РФ имя", "DOM.РФ застройщик", "Метод", "Score", "Note", "Дата", "Действия", ].map((h) => ( ))} {rows.map((r) => { const attention = coresMismatch( r.objective_complex_name, r.domrf_comm_name, ); const rowApproving = pendingApproveId === r.id; const rowRejecting = pendingRejectId === r.id; const rowBusy = rowApproving || rowRejecting; return ( ); })}
{h}
{r.objective_complex_name ?? "—"} {r.objective_group ? (
{r.objective_group}
) : null}
{r.objective_developers.length > 0 ? ( {r.objective_developers.join(", ")} ) : ( )} {r.domrf_comm_name ?? ( )} {r.domrf_obj_id != null ? (
obj {r.domrf_obj_id}
) : null}
{r.domrf_dev_name ?? ( )} {r.match_method ?? "—"} {formatScore(r.match_score)} {r.note ?? "—"} {formatIso(r.created_at)}
{r.is_reviewed ? ( проверен ) : ( )}
)}
{/* Confirm-диалог отклонения */} {confirmReject && (
{ if (!reject.isPending) setConfirmReject(null); }} >
e.stopPropagation()} >

Отклонить маппинг?

Маппинг будет удалён — сделки этого комплекса исчезнут из §4.2. Точно?

{confirmReject.objective_complex_name ?? "—"} →{" "} {confirmReject.domrf_comm_name ?? "—"}

{reject.isError && (

Ошибка: {(reject.error as Error).message}

)}
)} ); } // ── Стили (admin inline-паттерн, см. adminStyles) ───────────────────────────── const ellipsis: React.CSSProperties = { display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", }; const pageBtn = (disabled: boolean): React.CSSProperties => ({ padding: "6px 12px", background: "#f3f4f6", color: "#1f2937", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14, cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.5 : 1, }); const successBtn = (disabled: boolean): React.CSSProperties => ({ padding: "5px 10px", background: "#0a7a3a", color: "#fff", border: "none", borderRadius: 6, fontSize: 12, fontWeight: 600, cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.6 : 1, whiteSpace: "nowrap", }); const dangerBtn = (disabled: boolean): React.CSSProperties => ({ padding: "5px 10px", background: "#b3261e", color: "#fff", border: "none", borderRadius: 6, fontSize: 12, fontWeight: 600, cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.6 : 1, whiteSpace: "nowrap", }); const secondaryBtn = (disabled: boolean): React.CSSProperties => ({ padding: "8px 14px", background: "#f3f4f6", color: "#1f2937", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 13, cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.6 : 1, });