gendesign/tradein-mvp/frontend/src/components/trade-in/WhatIfPanel.tsx
bot-frontend 679e75b9e8
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m38s
Deploy Trade-In / deploy (push) Successful in 35s
fix(tradein): resilience-хвост аудита #770 (findings #25-33) (#793)
Co-authored-by: bot-frontend <bot-frontend@gendsgn.local>
Co-committed-by: bot-frontend <bot-frontend@gendsgn.local>
2026-05-30 17:13:31 +00:00

372 lines
14 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";
/**
* WhatIfPanel — «Что-если» интерактивный пересчёт оценки на результате.
* Web-преимущество над статичным PDF: продавец/агент крутит ключевые
* value-drivers (ремонт, этаж, площадь, балкон) и видит, как меняется оценка,
* не перезаполняя форму. Demo wow: «а если сделать ремонт — вот +X».
*
* Использует ТОТ ЖЕ POST /api/v1/trade-in/estimate (useEstimateMutation) — не
* параллельный API-путь. Запрос дебаунсится (~700 мс) и стреляет только если
* значение реально изменилось относительно последнего отправленного.
* Baseline (исходная оценка) держится снаружи и не затрагивается — панель
* показывает ДЕЛЬТУ к нему и не клобберит остальные карточки страницы.
*
* Каждый estimate ходит в скраперы/DaData, поэтому: debounce + skip-if-unchanged
* + ручная кнопка «Пересчитать». Авто-режим включён по умолчанию (autoMode),
* но переключается чекбоксом «Авто-пересчёт».
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
AggregatedEstimate,
RepairState,
TradeInEstimateInput,
} from "@/types/trade-in";
import { useEstimateMutation } from "@/lib/trade-in-api";
interface Props {
/** Исходная оценка-baseline — диффим относительно неё. */
baseEstimate: AggregatedEstimate;
/** Исходный input — основа для патчей (модифицируем копию). */
baseInput: TradeInEstimateInput;
}
const REPAIR_OPTIONS: ReadonlyArray<{ value: RepairState; label: string }> = [
{ value: "needs_repair", label: "Требует ремонта" },
{ value: "standard", label: "Жилое" },
{ value: "good", label: "Хороший" },
{ value: "excellent", label: "Евроремонт" },
];
const DEBOUNCE_MS = 700;
/** Цена-итог, по которой считаем дельту: ожидаемая сделка, иначе медиана. */
function headlinePrice(e: AggregatedEstimate): number {
const sold = e.expected_sold_price_rub;
return typeof sold === "number" && sold > 0 ? sold : e.median_price_rub;
}
function formatMln(rub: number): string {
return `${(rub / 1_000_000).toFixed(2).replace(".", ",")} млн`;
}
function formatDeltaMln(rub: number): string {
const sign = rub > 0 ? "+" : rub < 0 ? "" : "";
const abs = Math.abs(rub);
return `${sign}${(abs / 1_000_000).toFixed(2).replace(".", ",")} млн ₽`;
}
interface Draft {
repair_state: RepairState | "";
floor: number | null;
area_m2: number;
has_balcony: boolean;
}
function draftFromInput(input: TradeInEstimateInput, est: AggregatedEstimate): Draft {
return {
repair_state: (est.repair_state ?? input.repair_state ?? "") as RepairState | "",
floor: est.floor ?? input.floor ?? null,
area_m2: est.area_m2 ?? input.area_m2 ?? 0,
has_balcony: est.has_balcony ?? input.has_balcony ?? false,
};
}
/** Сериализуемый ключ драфта — для skip-if-unchanged сравнения. */
function draftKey(d: Draft): string {
return `${d.repair_state}|${d.floor ?? ""}|${d.area_m2}|${d.has_balcony ? 1 : 0}`;
}
function applyDraft(base: TradeInEstimateInput, d: Draft): TradeInEstimateInput {
const out: TradeInEstimateInput = {
...base,
area_m2: d.area_m2,
floor: d.floor,
has_balcony: d.has_balcony,
};
if (d.repair_state) out.repair_state = d.repair_state;
return out;
}
export function WhatIfPanel({ baseEstimate, baseInput }: Props) {
const initialDraft = useMemo(
() => draftFromInput(baseInput, baseEstimate),
[baseInput, baseEstimate],
);
const [draft, setDraft] = useState<Draft>(initialDraft);
const [autoMode, setAutoMode] = useState(true);
// Результат последнего what-if пересчёта (локальный, не трогает страницу).
const [whatIfEstimate, setWhatIfEstimate] = useState<AggregatedEstimate | null>(null);
const mutation = useEstimateMutation();
// Сброс при смене baseline (новая оценка из формы / restore по ?id=).
useEffect(() => {
setDraft(initialDraft);
setWhatIfEstimate(null);
}, [initialDraft]);
const baseKey = useMemo(() => draftKey(initialDraft), [initialDraft]);
const currentKey = draftKey(draft);
const isDirty = currentKey !== baseKey;
const totalFloors = baseEstimate.total_floors ?? baseInput.total_floors ?? null;
const floorMax = totalFloors && totalFloors > 0 ? totalFloors : 100;
// Ключ последнего ОТПРАВЛЕННОГО драфта — чтобы не дёргать бэкенд на тех же значениях.
const lastSentKeyRef = useRef<string>(baseKey);
// #770 finding #27: монотонный счётчик запросов — фильтрует out-of-order ответы
// (медленный старый пересчёт мог перетереть свежий результат).
const reqSeqRef = useRef(0);
useEffect(() => {
lastSentKeyRef.current = baseKey;
}, [baseKey]);
const recompute = useCallback(
(d: Draft) => {
const key = draftKey(d);
if (key === lastSentKeyRef.current) return; // ничего не изменилось
lastSentKeyRef.current = key;
const seq = ++reqSeqRef.current;
mutation.mutate(applyDraft(baseInput, d), {
onSuccess: (est) => {
// Применяем только результат последнего отправленного пересчёта.
if (seq === reqSeqRef.current) setWhatIfEstimate(est);
},
});
},
[baseInput, mutation],
);
// Debounced auto-recompute — только когда autoMode и draft реально отличается.
useEffect(() => {
if (!autoMode) return;
if (currentKey === lastSentKeyRef.current) return;
const t = setTimeout(() => recompute(draft), DEBOUNCE_MS);
return () => clearTimeout(t);
// currentKey покрывает все поля draft; recompute стабилен по baseInput.
}, [autoMode, currentKey, draft, recompute]);
function reset() {
setDraft(initialDraft);
setWhatIfEstimate(null);
lastSentKeyRef.current = baseKey;
}
const isPending = mutation.isPending;
// Активный результат для дельты: what-if если он отражает текущий драфт.
const shownEstimate =
isDirty && whatIfEstimate ? whatIfEstimate : null;
const basePrice = headlinePrice(baseEstimate);
const newPrice = shownEstimate ? headlinePrice(shownEstimate) : null;
const delta = newPrice !== null ? newPrice - basePrice : null;
const deltaPct = delta !== null && basePrice > 0 ? (delta / basePrice) * 100 : null;
function patch(p: Partial<Draft>) {
setDraft((s) => ({ ...s, ...p }));
}
return (
<article className="card whatif-card">
<div className="whatif-head">
<div>
<div className="section-kicker">Интерактив · Что-если</div>
<h3 className="whatif-title">Поменяйте параметры оценка пересчитается</h3>
</div>
<label className="whatif-auto" title="Автоматически пересчитывать после паузы">
<input
type="checkbox"
checked={autoMode}
onChange={(e) => setAutoMode(e.target.checked)}
/>
<span>Авто-пересчёт</span>
</label>
</div>
<div className="whatif-body">
{/* Состояние ремонта — segmented */}
<div className="whatif-field">
<span className="whatif-label">Состояние ремонта</span>
<div className="whatif-segmented" role="group" aria-label="Состояние ремонта">
{REPAIR_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`whatif-seg${draft.repair_state === opt.value ? " is-active" : ""}`}
aria-pressed={draft.repair_state === opt.value}
onClick={() => patch({ repair_state: opt.value })}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="whatif-row">
{/* Этаж — stepper + slider */}
<div className="whatif-field">
<span className="whatif-label">
Этаж{" "}
<span className="whatif-hint">
{draft.floor ?? "—"}
{totalFloors ? ` / ${totalFloors}` : ""}
</span>
</span>
<div className="whatif-stepper">
<button
type="button"
className="whatif-step-btn"
aria-label="Этаж ниже"
disabled={(draft.floor ?? 1) <= 1}
onClick={() => patch({ floor: Math.max(1, (draft.floor ?? 1) - 1) })}
>
</button>
<input
className="whatif-slider"
type="range"
min={1}
max={floorMax}
value={draft.floor ?? 1}
onChange={(e) => patch({ floor: Number(e.target.value) })}
aria-label="Этаж"
/>
<button
type="button"
className="whatif-step-btn"
aria-label="Этаж выше"
disabled={(draft.floor ?? 1) >= floorMax}
onClick={() => patch({ floor: Math.min(floorMax, (draft.floor ?? 1) + 1) })}
>
+
</button>
</div>
</div>
{/* Площадь — numeric ± */}
<div className="whatif-field">
<span className="whatif-label">
Площадь <span className="whatif-hint">м²</span>
</span>
<div className="whatif-stepper">
<button
type="button"
className="whatif-step-btn"
aria-label="Меньше площадь"
disabled={draft.area_m2 <= 11}
onClick={() =>
patch({ area_m2: Math.max(11, Math.round((draft.area_m2 - 1) * 10) / 10) })
}
>
</button>
<input
className="control mono whatif-num"
type="number"
step="0.5"
min={11}
max={499}
value={draft.area_m2}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isNaN(v)) patch({ area_m2: v });
}}
aria-label="Площадь м²"
/>
<button
type="button"
className="whatif-step-btn"
aria-label="Больше площадь"
disabled={draft.area_m2 >= 499}
onClick={() =>
patch({ area_m2: Math.min(499, Math.round((draft.area_m2 + 1) * 10) / 10) })
}
>
+
</button>
</div>
</div>
</div>
{/* Балкон toggle */}
<label className="whatif-balcony">
<input
type="checkbox"
checked={draft.has_balcony}
onChange={(e) => patch({ has_balcony: e.target.checked })}
/>
<span>Есть балкон / лоджия</span>
</label>
</div>
<div className="whatif-foot">
<div className="whatif-result" aria-live="polite">
{isPending ? (
<span className="whatif-loading">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
style={{ animation: "ti-spin 1s linear infinite" }}
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Пересчитываем
</span>
) : delta !== null && newPrice !== null ? (
<>
<span
className={`whatif-delta${delta > 0 ? " up" : delta < 0 ? " down" : ""}`}
title="Пересчёт ходит к источникам заново — дельта включает естественный разброс свежих объявлений, а не только эффект изменённого параметра."
>
{formatDeltaMln(delta)}
{deltaPct !== null && Math.abs(deltaPct) >= 0.1
? ` (${delta >= 0 ? "+" : ""}${Math.abs(deltaPct).toFixed(1)}%)`
: ""}
</span>
<span className="whatif-newprice mono">
новая оценка {formatMln(newPrice)}
</span>
</>
) : (
<span className="whatif-baseline mono">
{basePrice > 0 ? `исходная оценка ${formatMln(basePrice)}` : "исходная оценка —"}
</span>
)}
</div>
<div className="whatif-actions">
{!autoMode && (
<button
type="button"
className="btn btn-primary whatif-recalc"
disabled={!isDirty || isPending}
style={{ opacity: !isDirty || isPending ? 0.55 : 1 }}
onClick={() => recompute(draft)}
>
Пересчитать
</button>
)}
<button
type="button"
className="btn btn-ghost"
disabled={!isDirty && whatIfEstimate === null}
onClick={reset}
>
Сбросить
</button>
</div>
</div>
{mutation.error && (
<div className="whatif-error">{mutation.error.message}</div>
)}
</article>
);
}