571 lines
19 KiB
TypeScript
571 lines
19 KiB
TypeScript
"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,
|
||
});
|