gendesign/frontend/src/app/admin/mapping-review/page.tsx
bot-backend dfc1ee8be0
Some checks failed
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m47s
Deploy / build-worker (push) Successful in 5m42s
Deploy / build-frontend (push) Successful in 5m55s
Deploy / deploy (push) Has been cancelled
feat: ревью-инструмент авто-маппингов Объектив↔ДОМ.РФ (/admin/mapping-review) (#2278)
2026-07-03 07:59:13 +00:00

571 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<MappingReviewRow | null>(
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<MappingReviewResponse>(
`/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 (
<>
<h2 style={{ marginTop: 0, fontSize: 18 }}>
Ревью авто-маппингов ЖК DOM.РФ
</h2>
<p style={{ color: "#5b6066", fontSize: 13, marginTop: 4 }}>
Строки <code>objective_complex_mapping</code>, связавшие
objective-проект с объектом DOM.РФ автоматически (fuzzy / core-dev /
geo). Сомнительные сверху (сорт с бэка). <strong>Подтвердить</strong>{" "}
помечает маппинг проверенным. <strong>Отклонить</strong> удаляет строку
сделки этого комплекса исчезнут из §4.2. Строки, где ядро
objective-имени не совпадает с ядром DOM.РФ, подсвечены жёлтым для
внимания (эвристика, не приговор).
</p>
{/* Ошибка загрузки списка */}
{list.isError && (
<div
style={{
marginTop: 16,
padding: 16,
background: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: 6,
color: "#991b1b",
fontSize: 13,
}}
>
Ошибка загрузки списка: {(list.error as Error).message}
</div>
)}
{/* Фильтр + пагинация header */}
<section style={cardStyle}>
<div
style={{
display: "flex",
gap: 16,
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
}}
>
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "#374151",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={onlyUnreviewed}
onChange={(e) => {
setOnlyUnreviewed(e.target.checked);
setOffset(0);
}}
/>
Только непроверенные
</label>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
fontSize: 13,
color: "#5b6066",
}}
>
<span>
{list.isLoading ? (
"Загрузка…"
) : (
<>
{pageFrom}{pageTo} из{" "}
<strong>{total.toLocaleString("ru")}</strong>
</>
)}
</span>
<button
type="button"
onClick={() => setOffset((o) => Math.max(0, o - LIMIT))}
disabled={!canPrev}
style={pageBtn(!canPrev)}
aria-label="Предыдущая страница"
>
</button>
<button
type="button"
onClick={() => setOffset((o) => o + LIMIT)}
disabled={!canNext}
style={pageBtn(!canNext)}
aria-label="Следующая страница"
>
</button>
</div>
</div>
</section>
{/* Ошибка мутаций (inline, не привязана к строке) */}
{(approve.isError || reject.isError) && (
<div
style={{
marginTop: 16,
padding: 12,
background: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: 6,
color: "#991b1b",
fontSize: 13,
}}
>
{approve.isError &&
`Не удалось подтвердить: ${(approve.error as Error).message}`}
{reject.isError &&
`Не удалось отклонить: ${(reject.error as Error).message}`}
</div>
)}
{/* Таблица */}
<section style={cardStyle}>
{!list.isLoading && rows.length === 0 ? (
<p style={{ color: "#5b6066", fontSize: 13, margin: 0 }}>
{onlyUnreviewed
? "Непроверенных маппингов нет — всё отревьюено."
: "Маппингов не найдено."}
</p>
) : (
<div style={{ overflowX: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 13,
}}
>
<thead>
<tr style={{ background: "#f6f7f9" }}>
{[
"Объектив (ЖК)",
"Объектив-девелоперы",
"DOM.РФ имя",
"DOM.РФ застройщик",
"Метод",
"Score",
"Note",
"Дата",
"Действия",
].map((h) => (
<th key={h} style={th}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{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 (
<tr
key={r.id}
style={{
borderBottom: "1px solid #eef0f3",
background: attention ? "var(--warn-soft)" : "#fff",
}}
>
<td style={{ ...td, maxWidth: 260 }}>
<span
style={{ fontWeight: 500 }}
title={r.objective_complex_name ?? ""}
>
{r.objective_complex_name ?? "—"}
</span>
{r.objective_group ? (
<div style={{ fontSize: 11, color: "#73767e" }}>
{r.objective_group}
</div>
) : null}
</td>
<td
style={{ ...td, maxWidth: 200, fontSize: 12 }}
title={r.objective_developers.join(", ")}
>
{r.objective_developers.length > 0 ? (
<span style={ellipsis}>
{r.objective_developers.join(", ")}
</span>
) : (
<span style={{ color: "#9ca3af" }}></span>
)}
</td>
<td
style={{ ...td, maxWidth: 280 }}
title={r.domrf_comm_name ?? ""}
>
<span style={ellipsis}>
{r.domrf_comm_name ?? (
<span style={{ color: "#9ca3af" }}></span>
)}
</span>
{r.domrf_obj_id != null ? (
<div
style={{
fontSize: 11,
color: "#73767e",
fontFamily: "monospace",
}}
>
obj {r.domrf_obj_id}
</div>
) : null}
</td>
<td
style={{ ...td, maxWidth: 200, fontSize: 12 }}
title={r.domrf_dev_name ?? ""}
>
<span style={ellipsis}>
{r.domrf_dev_name ?? (
<span style={{ color: "#9ca3af" }}></span>
)}
</span>
</td>
<td style={{ ...td, fontSize: 12 }}>
<span style={{ fontFamily: "monospace" }}>
{r.match_method ?? "—"}
</span>
</td>
<td
style={{
...td,
fontFamily: "monospace",
textAlign: "right",
fontVariantNumeric: "tabular-nums",
}}
>
{formatScore(r.match_score)}
</td>
<td
style={{
...td,
maxWidth: 160,
fontSize: 12,
color: "#5b6066",
}}
title={r.note ?? ""}
>
<span style={ellipsis}>{r.note ?? "—"}</span>
</td>
<td style={{ ...td, whiteSpace: "nowrap", fontSize: 12 }}>
{formatIso(r.created_at)}
</td>
<td style={{ ...td, whiteSpace: "nowrap" }}>
<div style={{ display: "flex", gap: 6 }}>
{r.is_reviewed ? (
<Badge variant="success" size="sm">
проверен
</Badge>
) : (
<button
type="button"
onClick={() => approve.mutate(r.id)}
disabled={rowBusy}
style={successBtn(rowBusy)}
>
{rowApproving ? "…" : "Подтвердить"}
</button>
)}
<button
type="button"
onClick={() => setConfirmReject(r)}
disabled={rowBusy}
style={dangerBtn(rowBusy)}
>
{rowRejecting ? "…" : "Отклонить"}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
{/* Confirm-диалог отклонения */}
{confirmReject && (
<div
role="dialog"
aria-modal="true"
style={{
position: "fixed",
inset: 0,
background: "rgba(15, 23, 42, 0.45)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
padding: 16,
}}
onClick={() => {
if (!reject.isPending) setConfirmReject(null);
}}
>
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 24,
maxWidth: 460,
width: "100%",
boxShadow: "0 10px 40px rgba(0,0,0,0.2)",
}}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ margin: "0 0 12px", fontSize: 16, fontWeight: 600 }}>
Отклонить маппинг?
</h3>
<p style={{ margin: "0 0 8px", fontSize: 13, color: "#374151" }}>
Маппинг будет <strong>удалён</strong> сделки этого комплекса
исчезнут из §4.2. Точно?
</p>
<p
style={{
margin: "0 0 20px",
fontSize: 12,
color: "#5b6066",
background: "#f6f7f9",
borderRadius: 6,
padding: "8px 10px",
}}
>
<strong>{confirmReject.objective_complex_name ?? "—"}</strong> {" "}
{confirmReject.domrf_comm_name ?? "—"}
</p>
{reject.isError && (
<p style={{ margin: "0 0 12px", fontSize: 12, color: "#b3261e" }}>
Ошибка: {(reject.error as Error).message}
</p>
)}
<div
style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}
>
<button
type="button"
onClick={() => setConfirmReject(null)}
disabled={reject.isPending}
style={secondaryBtn(reject.isPending)}
>
Отмена
</button>
<button
type="button"
onClick={() => reject.mutate(confirmReject.id)}
disabled={reject.isPending}
style={dangerBtn(reject.isPending)}
>
{reject.isPending ? "Удаление…" : "Отклонить и удалить"}
</button>
</div>
</div>
</div>
)}
</>
);
}
// ── Стили (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,
});