Compare commits
No commits in common. "fb25180929f70f2f46fe59397624e8bde7157ba7" and "f3e10a04adde40117128fbda1f21e09655ca31e9" have entirely different histories.
fb25180929
...
f3e10a04ad
7 changed files with 320 additions and 322 deletions
|
|
@ -34,7 +34,6 @@ import {
|
||||||
mapAnalytics,
|
mapAnalytics,
|
||||||
mapCache,
|
mapCache,
|
||||||
mapHistory,
|
mapHistory,
|
||||||
mapMarkers,
|
|
||||||
mapObject,
|
mapObject,
|
||||||
mapReport,
|
mapReport,
|
||||||
mapResultPanel,
|
mapResultPanel,
|
||||||
|
|
@ -58,8 +57,6 @@ import {
|
||||||
useStreetDeals,
|
useStreetDeals,
|
||||||
} from "@/lib/trade-in-api";
|
} from "@/lib/trade-in-api";
|
||||||
import { useQuota } from "@/lib/useQuota";
|
import { useQuota } from "@/lib/useQuota";
|
||||||
import { useMe } from "@/lib/useMe";
|
|
||||||
import { logout } from "@/lib/logout";
|
|
||||||
|
|
||||||
// OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative,
|
// OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative,
|
||||||
// non-interactive overlay drawn over the artboard gradient. The frame has
|
// non-interactive overlay drawn over the artboard gradient. The frame has
|
||||||
|
|
@ -412,11 +409,6 @@ export default function TradeInV2Page() {
|
||||||
// via invalidateQueries in handleSubmit.
|
// via invalidateQueries in handleSubmit.
|
||||||
const history = useEstimateHistory();
|
const history = useEstimateHistory();
|
||||||
const quota = useQuota();
|
const quota = useQuota();
|
||||||
// Real logged-in identity for the TopNav user menu (replaces the design
|
|
||||||
// fixture). /me returns {username, role, brand} — no display name / email, so
|
|
||||||
// name falls back to username, org to brand ?? role, email to "" (renders
|
|
||||||
// blank, never invented). undefined while loading → TopNav «Гость» fallback.
|
|
||||||
const me = useMe();
|
|
||||||
|
|
||||||
// Dashboard sub-hooks — each resolves independently; failure degrades its
|
// Dashboard sub-hooks — each resolves independently; failure degrades its
|
||||||
// section via the mappers (null input) rather than blanking the page.
|
// section via the mappers (null input) rather than blanking the page.
|
||||||
|
|
@ -505,24 +497,6 @@ export default function TradeInV2Page() {
|
||||||
// to the fixture default (undefined) for unwired/pre-load states.
|
// to the fixture default (undefined) for unwired/pre-load states.
|
||||||
const reportsCount = quota.data?.used ?? history.data?.length;
|
const reportsCount = quota.data?.used ?? history.data?.length;
|
||||||
|
|
||||||
// Real user for the TopNav menu. undefined until /me resolves → TopNav shows
|
|
||||||
// its neutral «Гость» fallback (never the old "Андрей Петров / Брусника").
|
|
||||||
const topNavUser = useMemo(() => {
|
|
||||||
const scope = me.data;
|
|
||||||
if (!scope) return undefined;
|
|
||||||
return {
|
|
||||||
name: scope.username,
|
|
||||||
org: scope.brand ?? scope.role,
|
|
||||||
email: "",
|
|
||||||
initials: scope.username.slice(0, 2).toUpperCase(),
|
|
||||||
};
|
|
||||||
}, [me.data]);
|
|
||||||
|
|
||||||
// Analog price pins for the 01 map, projected from the real estimate. No
|
|
||||||
// estimate → mapMarkers(null) → [] → ParamsPanel renders only the subject pin
|
|
||||||
// (Finding #2: never the static fixture price markers).
|
|
||||||
const markers = useMemo(() => mapMarkers(estimate), [estimate]);
|
|
||||||
|
|
||||||
// Prefill for restore-by-id / re-estimate. Keyed remount applies it when the
|
// Prefill for restore-by-id / re-estimate. Keyed remount applies it when the
|
||||||
// estimate arrives async (useState reads initialValues only on mount).
|
// estimate arrives async (useState reads initialValues only on mount).
|
||||||
const initialValues = useMemo<Partial<TradeInEstimateInput> | undefined>(
|
const initialValues = useMemo<Partial<TradeInEstimateInput> | undefined>(
|
||||||
|
|
@ -652,13 +626,7 @@ export default function TradeInV2Page() {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TopNav
|
<TopNav active={nav} onNavigate={setNav} reports={reportsCount ?? 0} />
|
||||||
active={nav}
|
|
||||||
onNavigate={setNav}
|
|
||||||
reports={reportsCount ?? 0}
|
|
||||||
user={topNavUser}
|
|
||||||
onLogout={logout}
|
|
||||||
/>
|
|
||||||
<HeroBar
|
<HeroBar
|
||||||
data={{ report, object: objectInfo }}
|
data={{ report, object: objectInfo }}
|
||||||
estimateId={mounted ? currentEstimateId : null}
|
estimateId={mounted ? currentEstimateId : null}
|
||||||
|
|
@ -680,7 +648,6 @@ export default function TradeInV2Page() {
|
||||||
isPending={mutation.isPending}
|
isPending={mutation.isPending}
|
||||||
error={apiError}
|
error={apiError}
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
markers={markers}
|
|
||||||
/>
|
/>
|
||||||
{middleContent}
|
{middleContent}
|
||||||
{rightContent}
|
{rightContent}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, type CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
import { API_BASE_URL } from "@/lib/api";
|
import { API_BASE_URL } from "@/lib/api";
|
||||||
|
|
@ -60,9 +60,6 @@ export default function HeroBar({
|
||||||
onOpenInfo,
|
onOpenInfo,
|
||||||
}: HeroBarProps) {
|
}: HeroBarProps) {
|
||||||
const pdfHref = pdfDownloadHref(estimateId);
|
const pdfHref = pdfDownloadHref(estimateId);
|
||||||
// Hide the building photo if the asset 404s/400s so the photoBg fill shows
|
|
||||||
// instead of a broken-image icon.
|
|
||||||
const [imgFailed, setImgFailed] = useState(false);
|
|
||||||
const pdfBtnInner = (
|
const pdfBtnInner = (
|
||||||
<>
|
<>
|
||||||
<svg width="18" height="20" viewBox="0 0 18 20" fill="none">
|
<svg width="18" height="20" viewBox="0 0 18 20" fill="none">
|
||||||
|
|
@ -205,7 +202,6 @@ export default function HeroBar({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="hero-calc-btn"
|
className="hero-calc-btn"
|
||||||
onClick={onOpenInfo}
|
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: 50,
|
height: 50,
|
||||||
|
|
@ -243,21 +239,17 @@ export default function HeroBar({
|
||||||
boxShadow: "0 6px 26px rgba(40,80,130,.10)",
|
boxShadow: "0 6px 26px rgba(40,80,130,.10)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!imgFailed && (
|
<Image
|
||||||
<Image
|
src="/trade-in-v2/building.png"
|
||||||
src="/trade-in-v2/building.png"
|
alt=""
|
||||||
alt=""
|
fill
|
||||||
fill
|
sizes="560px"
|
||||||
sizes="560px"
|
style={{
|
||||||
unoptimized
|
objectFit: "contain",
|
||||||
onError={() => setImgFailed(true)}
|
objectPosition: "right center",
|
||||||
style={{
|
filter: "saturate(.9) brightness(1.04)",
|
||||||
objectFit: "contain",
|
}}
|
||||||
objectPosition: "right center",
|
/>
|
||||||
filter: "saturate(.9) brightness(1.04)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { history, dkpRows } from "./fixtures";
|
import { history, dkpRows } from "./fixtures";
|
||||||
import { pluralRu } from "./mappers";
|
|
||||||
import type { HistoryData } from "./mappers";
|
import type { HistoryData } from "./mappers";
|
||||||
|
|
||||||
// Shared grid templates (kept verbatim from the design column tracks).
|
// Shared grid templates (kept verbatim from the design column tracks).
|
||||||
|
|
@ -59,8 +58,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
|
||||||
color: tokens.muted2,
|
color: tokens.muted2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.houseSales.length}{" "}
|
{data.houseSales.length} ЛОТА
|
||||||
{pluralRu(data.houseSales.length, ["ЛОТ", "ЛОТА", "ЛОТОВ"])}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,73 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// "ПОЯСНЕНИЕ К РАСЧЁТУ" right-side drawer for the /trade-in/v2 "МЕРА Оценка"
|
// "КОЭФФИЦИЕНТ ЛОКАЦИИ" right-side drawer for the /trade-in/v2 "МЕРА Оценка"
|
||||||
// design port. Opened from HeroBar (the "?" near "КОЭФ. ЛОКАЦИИ"). This used to
|
// design port. Faithful markup port of the design drawer (МЕРА Оценка.dc.html,
|
||||||
// render a FABRICATED location coefficient (0.87), a fake "base × coef = result"
|
// lines 618-660): scrim + 452px right slider that slides in via translateX, a
|
||||||
// formula and invented POI factor lists with a false "Источник: OpenStreetMap"
|
// big location coefficient, intro, base×coef formula, the factors that raise /
|
||||||
// footer — none of which the backend produces (location-coef is deferred,
|
// lower price, and a geo-source footer. Static markup, data from fixtures.
|
||||||
// backend #2045). It is now an HONEST methodology panel with STATIC, truthful
|
// Open/close is driven entirely by props (no internal state, no fetch).
|
||||||
// documentation text only: how the estimate is built, and an explicit note that
|
|
||||||
// the location coefficient does not exist yet. Keeps the same drawer shell /
|
|
||||||
// slide animation / close button. Open/close is driven entirely by props; the
|
|
||||||
// only effect is an Escape-to-close keydown listener while open.
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
|
import type { LocationFactor } from "./types";
|
||||||
|
import { locationFactors } from "./fixtures";
|
||||||
|
|
||||||
interface LocationDrawerProps {
|
interface LocationDrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
function FactorRow({
|
||||||
// Escape-to-close (UI effect only — no HTTP). Active while the drawer is open.
|
factor,
|
||||||
useEffect(() => {
|
variant,
|
||||||
if (!open) return;
|
}: {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
factor: LocationFactor;
|
||||||
if (e.key === "Escape") onClose();
|
variant: "pos" | "neg";
|
||||||
};
|
}) {
|
||||||
window.addEventListener("keydown", onKey);
|
const isPos = variant === "pos";
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return (
|
||||||
}, [open, onClose]);
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
fontSize: 12,
|
||||||
|
color: tokens.body,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: isPos
|
||||||
|
? "rgba(27,170,107,.12)"
|
||||||
|
: "rgba(111,129,149,.14)",
|
||||||
|
color: isPos ? tokens.success : tokens.muted,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: isPos ? 11 : 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPos ? "+" : "−"}
|
||||||
|
</span>
|
||||||
|
{factor.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: tokens.font.mono,
|
||||||
|
fontSize: 11,
|
||||||
|
color: isPos ? tokens.success : tokens.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{factor.delta}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{`
|
<style>{`
|
||||||
|
|
@ -144,7 +182,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
||||||
color: tokens.muted2,
|
color: tokens.muted2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
МЕТОДИКА
|
ПОЯСНЕНИЕ К РАСЧЁТУ
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -155,7 +193,7 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
ПОЯСНЕНИЕ К РАСЧЁТУ
|
КОЭФФИЦИЕНТ ЛОКАЦИИ
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ld-close" onClick={onClose}>
|
<div className="ld-close" onClick={onClose}>
|
||||||
|
|
@ -163,47 +201,81 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* divider under header */}
|
{/* big coefficient */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: 22,
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
gap: 14,
|
||||||
|
marginTop: 24,
|
||||||
|
paddingBottom: 20,
|
||||||
borderBottom: `1px solid ${tokens.lineSoft}`,
|
borderBottom: `1px solid ${tokens.lineSoft}`,
|
||||||
}}
|
}}
|
||||||
/>
|
|
||||||
|
|
||||||
{/* methodology section */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 9,
|
|
||||||
letterSpacing: "2px",
|
|
||||||
color: tokens.muted2,
|
|
||||||
marginTop: 22,
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
КАК СЧИТАЕМ
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: tokens.font.mono,
|
||||||
|
fontSize: 52,
|
||||||
|
fontWeight: 300,
|
||||||
|
color: tokens.accent,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{locationFactors.coef}
|
||||||
|
</span>
|
||||||
|
<div style={{ fontSize: 11, color: tokens.muted, lineHeight: 1.5 }}>
|
||||||
|
множитель к базовой
|
||||||
|
<br />
|
||||||
|
цене по городу
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* intro */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12.5,
|
fontSize: 12.5,
|
||||||
lineHeight: 1.7,
|
lineHeight: 1.7,
|
||||||
color: tokens.body2,
|
color: tokens.body2,
|
||||||
|
marginTop: 20,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Агрегируем объявления (Авито, Циан, Яндекс, Домклик) и сделки
|
Показывает, как адрес корректирует стоимость относительно медианы по
|
||||||
Росреестра по сопоставимым квартирам. Медиана{" "}
|
Екатеринбургу. <b style={{ color: tokens.ink2 }}>1.00</b> — средний
|
||||||
<b style={{ color: tokens.ink2 }}>₽/м²</b> → 3 оценки:{" "}
|
уровень. <b style={{ color: tokens.accent }}>0.87</b> означает, что
|
||||||
<b style={{ color: tokens.ink2 }}>
|
локация снижает цену примерно на{" "}
|
||||||
рекомендованная цена в объявлении
|
<b style={{ color: tokens.ink2 }}>13%</b> из-за баланса факторов ниже.
|
||||||
</b>{" "}
|
|
||||||
(asking),{" "}
|
|
||||||
<b style={{ color: tokens.ink2 }}>ожидаемая цена сделки</b> (asking −
|
|
||||||
торг по ДКП),{" "}
|
|
||||||
<b style={{ color: tokens.ink2 }}>ДКП·Росреестр</b> (фактические
|
|
||||||
сделки). CV — коэффициент вариации выборки (разброс цен).
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* location note section */}
|
{/* formula */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
marginTop: 18,
|
||||||
|
background: tokens.surface.w55,
|
||||||
|
border: `1px solid ${tokens.lineSoft}`,
|
||||||
|
borderRadius: 7,
|
||||||
|
padding: "12px 14px",
|
||||||
|
fontFamily: tokens.font.mono,
|
||||||
|
fontSize: 12,
|
||||||
|
color: tokens.ink2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: tokens.muted }}>
|
||||||
|
{locationFactors.formula.base}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: tokens.muted2 }}>×</span>
|
||||||
|
<span style={{ color: tokens.accent }}>
|
||||||
|
{locationFactors.formula.coef}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: tokens.muted2 }}>=</span>
|
||||||
|
<span style={{ fontWeight: 500 }}>
|
||||||
|
{locationFactors.formula.result}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* positives */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
|
|
@ -213,22 +285,44 @@ export function LocationDrawer({ open, onClose }: LocationDrawerProps) {
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
ЛОКАЦИЯ
|
ЧТО ПОВЫШАЕТ ЦЕНУ
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
|
||||||
|
{locationFactors.positives.map((f, i) => (
|
||||||
|
<FactorRow key={i} factor={f} variant="pos" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* negatives */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12.5,
|
fontSize: 9,
|
||||||
lineHeight: 1.7,
|
letterSpacing: "2px",
|
||||||
color: tokens.body2,
|
color: tokens.muted2,
|
||||||
background: tokens.infoSoftBg,
|
marginTop: 24,
|
||||||
border: `1px solid ${tokens.infoSoftBorder}`,
|
marginBottom: 12,
|
||||||
borderRadius: 7,
|
|
||||||
padding: "12px 14px",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Коэффициент локации (POI, транспорт, шум) —{" "}
|
ЧТО СНИЖАЕТ ЦЕНУ
|
||||||
<b style={{ color: tokens.ink2 }}>в разработке</b>; текущая оценка по
|
</div>
|
||||||
локации не корректируется.
|
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
|
||||||
|
{locationFactors.negatives.map((f, i) => (
|
||||||
|
<FactorRow key={i} factor={f} variant="neg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* footer source */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 24,
|
||||||
|
paddingTop: 16,
|
||||||
|
borderTop: `1px solid ${tokens.lineSoft}`,
|
||||||
|
fontSize: 10.5,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
color: tokens.hint,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{locationFactors.footer}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import {
|
||||||
HOUSE_TYPE_RU,
|
HOUSE_TYPE_RU,
|
||||||
REPAIR_FROM_RU,
|
REPAIR_FROM_RU,
|
||||||
REPAIR_RU,
|
REPAIR_RU,
|
||||||
type MapMarker,
|
|
||||||
} from "./mappers";
|
} from "./mappers";
|
||||||
import { useGeocodeSuggest } from "@/lib/trade-in-api";
|
import { useGeocodeSuggest } from "@/lib/trade-in-api";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -258,21 +257,6 @@ const errorText: CSSProperties = {
|
||||||
color: tokens.danger,
|
color: tokens.danger,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Analog price pin on the 01 map. left/top come from mapMarkers() per-analog
|
|
||||||
// (projected from the real estimate); the chrome matches the former fixture
|
|
||||||
// pins 1:1 — only the data source changed (Finding #2).
|
|
||||||
const analogPin: CSSProperties = {
|
|
||||||
position: "absolute",
|
|
||||||
background: tokens.surface.w70,
|
|
||||||
border: `1px solid ${tokens.line}`,
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: "3px 7px",
|
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
fontSize: 9,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
color: tokens.ink,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ParamsPanelProps {
|
interface ParamsPanelProps {
|
||||||
/** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional so
|
/** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional so
|
||||||
* the still-unwired v2 page renders without props (tsc-safe). */
|
* the still-unwired v2 page renders without props (tsc-safe). */
|
||||||
|
|
@ -283,9 +267,6 @@ interface ParamsPanelProps {
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
|
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
|
||||||
initialValues?: Partial<TradeInEstimateInput>;
|
initialValues?: Partial<TradeInEstimateInput>;
|
||||||
/** Analog price pins for the 01 map, projected from the real estimate via
|
|
||||||
* mapMarkers(). Default [] → no price pins (never the fabricated fixtures). */
|
|
||||||
markers?: MapMarker[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// rooms number -> dropdown label. The design has no «Студия» option, so studio
|
// rooms number -> dropdown label. The design has no «Студия» option, so studio
|
||||||
|
|
@ -310,7 +291,6 @@ export default function ParamsPanel({
|
||||||
isPending = false,
|
isPending = false,
|
||||||
error = null,
|
error = null,
|
||||||
initialValues,
|
initialValues,
|
||||||
markers = [],
|
|
||||||
}: ParamsPanelProps) {
|
}: ParamsPanelProps) {
|
||||||
const [openDd, setOpenDd] = useState<DdKey>(null);
|
const [openDd, setOpenDd] = useState<DdKey>(null);
|
||||||
const [address, setAddress] = useState(initialValues?.address ?? "");
|
const [address, setAddress] = useState(initialValues?.address ?? "");
|
||||||
|
|
@ -386,11 +366,7 @@ export default function ParamsPanel({
|
||||||
const trimmedAddress = address.trim();
|
const trimmedAddress = address.trim();
|
||||||
const areaNum = Number(area.replace(",", "."));
|
const areaNum = Number(area.replace(",", "."));
|
||||||
if (trimmedAddress.length < 3) {
|
if (trimmedAddress.length < 3) {
|
||||||
setValidationError("Укажите адрес квартиры — минимум 3 символа");
|
setValidationError("Укажите адрес — минимум 3 символа");
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!area.trim()) {
|
|
||||||
setValidationError("Укажите площадь квартиры");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!Number.isFinite(areaNum) || areaNum <= 10) {
|
if (!Number.isFinite(areaNum) || areaNum <= 10) {
|
||||||
|
|
@ -605,16 +581,135 @@ export default function ParamsPanel({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* analog price pins — projected from the real estimate via mapMarkers().
|
{/* analog price pins */}
|
||||||
Empty markers (no estimate yet) → no price pins, just the subject pin +
|
<div
|
||||||
radius rings (Finding #2: never the fabricated fixture prices/dots). */}
|
style={{
|
||||||
{markers.map((m, i) => (
|
position: "absolute",
|
||||||
<div key={i} style={{ ...analogPin, left: m.left, top: m.top }}>
|
left: "5%",
|
||||||
{m.label}
|
top: "13%",
|
||||||
<br />
|
background: tokens.surface.w70,
|
||||||
<span style={{ color: tokens.muted }}>{m.sub}</span>
|
border: `1px solid ${tokens.line}`,
|
||||||
</div>
|
borderRadius: 4,
|
||||||
))}
|
padding: "3px 7px",
|
||||||
|
fontFamily: tokens.font.mono,
|
||||||
|
fontSize: 9,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
color: tokens.ink,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
12,7 млн ₽<br />
|
||||||
|
<span style={{ color: tokens.muted }}>54 м²</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: "32%",
|
||||||
|
top: "8%",
|
||||||
|
background: tokens.surface.w70,
|
||||||
|
border: `1px solid ${tokens.line}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "3px 7px",
|
||||||
|
fontFamily: tokens.font.mono,
|
||||||
|
fontSize: 9,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
color: tokens.ink,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
7,6 млн ₽<br />
|
||||||
|
<span style={{ color: tokens.muted }}>44 м²</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: "3%",
|
||||||
|
top: "36%",
|
||||||
|
background: tokens.surface.w70,
|
||||||
|
border: `1px solid ${tokens.line}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "3px 7px",
|
||||||
|
fontFamily: tokens.font.mono,
|
||||||
|
fontSize: 9,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
color: tokens.ink,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
22,4 млн ₽<br />
|
||||||
|
<span style={{ color: tokens.muted }}>55 м²</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "13%",
|
||||||
|
bottom: "9%",
|
||||||
|
background: tokens.surface.w70,
|
||||||
|
border: `1px solid ${tokens.line}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "3px 7px",
|
||||||
|
fontFamily: tokens.font.mono,
|
||||||
|
fontSize: 9,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
color: tokens.ink,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
15,9 млн ₽<br />
|
||||||
|
<span style={{ color: tokens.muted }}>55 м²</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: "14%",
|
||||||
|
bottom: "11%",
|
||||||
|
background: tokens.surface.w70,
|
||||||
|
border: `1px solid ${tokens.line}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "3px 7px",
|
||||||
|
fontFamily: tokens.font.mono,
|
||||||
|
fontSize: 9,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
color: tokens.ink,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
14,9 млн ₽<br />
|
||||||
|
<span style={{ color: tokens.muted }}>56 м²</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* analog dots */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "18%",
|
||||||
|
top: "30%",
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: tokens.accent,
|
||||||
|
border: `1.5px solid ${tokens.dotStroke}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: "34%",
|
||||||
|
top: "28%",
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: tokens.dealDot,
|
||||||
|
border: `1.5px solid ${tokens.dotStroke}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "30%",
|
||||||
|
bottom: "24%",
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: tokens.accent,
|
||||||
|
border: `1.5px solid ${tokens.dotStroke}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* map controls */}
|
{/* map controls */}
|
||||||
<div
|
<div
|
||||||
|
|
@ -867,7 +962,7 @@ export default function ParamsPanel({
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") handleSubmit();
|
if (e.key === "Enter") handleSubmit();
|
||||||
}}
|
}}
|
||||||
placeholder="напр. 54"
|
placeholder="54"
|
||||||
style={inputField}
|
style={inputField}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,46 +4,23 @@
|
||||||
// Faithful markup port of the design header (МЕРА Оценка.dc.html, lines 42-90):
|
// Faithful markup port of the design header (МЕРА Оценка.dc.html, lines 42-90):
|
||||||
// inline SVG logo + version + 5 nav tabs (active underline/triangle) + user menu.
|
// inline SVG logo + version + 5 nav tabs (active underline/triangle) + user menu.
|
||||||
// Tabs change only local UI state via onNavigate; the user dropdown owns its
|
// Tabs change only local UI state via onNavigate; the user dropdown owns its
|
||||||
// own useState. No data fetching — labels/version come from fixtures, the user
|
// own useState. No data fetching — labels/user come from fixtures, colours from
|
||||||
// identity is fed in from the page (real useMe), colours from tokens.
|
// tokens.
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { navLabels, version } from "./fixtures";
|
import { navLabels, user, version } from "./fixtures";
|
||||||
|
|
||||||
// Real logged-in user identity, derived by the page from useMe()
|
|
||||||
// ({username, role, brand}). Deliberately excludes `reports` — the «Мои
|
|
||||||
// отчёты» badge count is a separate prop fed from useQuota().used.
|
|
||||||
interface TopNavUser {
|
|
||||||
name: string;
|
|
||||||
org: string;
|
|
||||||
email: string;
|
|
||||||
initials: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TopNavProps {
|
interface TopNavProps {
|
||||||
active: number;
|
active: number;
|
||||||
onNavigate: (i: number) => void;
|
onNavigate: (i: number) => void;
|
||||||
// «Мои отчёты» badge count — page feeds it from useQuota().used (per-user
|
// «Мои отчёты» badge count — page feeds it from useQuota().used (per-user
|
||||||
// estimate count). Defaults to 0 for unwired usage.
|
// estimate count). Defaults to the design fixture for unwired usage.
|
||||||
reports?: number;
|
reports?: number;
|
||||||
// Real logged-in user. undefined → neutral «Гость» placeholder, NEVER the
|
|
||||||
// old design fixture ("Андрей Петров / Брусника").
|
|
||||||
user?: TopNavUser;
|
|
||||||
// Sign out — page wires this to logout(); closes the menu first.
|
|
||||||
onLogout?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Neutral fallback when the user prop is absent (loading / unauthenticated).
|
|
||||||
const GUEST_USER: TopNavUser = {
|
|
||||||
name: "Гость",
|
|
||||||
org: "—",
|
|
||||||
email: "—",
|
|
||||||
initials: "—",
|
|
||||||
};
|
|
||||||
|
|
||||||
const menuItemStyle: CSSProperties = {
|
const menuItemStyle: CSSProperties = {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|
@ -56,23 +33,12 @@ const menuItemStyle: CSSProperties = {
|
||||||
transition: "background .12s",
|
transition: "background .12s",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Профиль / Настройки / Помощь have no pages yet — render them dimmed and
|
|
||||||
// non-interactive (no hover class, default cursor) so they read as disabled.
|
|
||||||
const menuItemDisabledStyle: CSSProperties = {
|
|
||||||
...menuItemStyle,
|
|
||||||
cursor: "default",
|
|
||||||
color: tokens.muted2,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TopNav({
|
export default function TopNav({
|
||||||
active,
|
active,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
reports = 0,
|
reports = user.reports,
|
||||||
user,
|
|
||||||
onLogout,
|
|
||||||
}: TopNavProps) {
|
}: TopNavProps) {
|
||||||
const [userOpen, setUserOpen] = useState(false);
|
const [userOpen, setUserOpen] = useState(false);
|
||||||
const u = user ?? GUEST_USER;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -256,7 +222,7 @@ export default function TopNav({
|
||||||
boxShadow: "0 3px 10px rgba(46,139,255,.3)",
|
boxShadow: "0 3px 10px rgba(46,139,255,.3)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{u.initials}
|
{user.initials}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ lineHeight: 1.25 }}>
|
<div style={{ lineHeight: 1.25 }}>
|
||||||
<div
|
<div
|
||||||
|
|
@ -267,7 +233,7 @@ export default function TopNav({
|
||||||
fontFamily: tokens.font.sans,
|
fontFamily: tokens.font.sans,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{u.name}
|
{user.name}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -277,7 +243,7 @@ export default function TopNav({
|
||||||
letterSpacing: ".5px",
|
letterSpacing: ".5px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{u.org}
|
{user.org}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ color: tokens.muted2, fontSize: "9px" }}>▾</span>
|
<span style={{ color: tokens.muted2, fontSize: "9px" }}>▾</span>
|
||||||
|
|
@ -323,7 +289,7 @@ export default function TopNav({
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{u.initials}
|
{user.initials}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
|
|
@ -333,15 +299,15 @@ export default function TopNav({
|
||||||
color: tokens.ink2,
|
color: tokens.ink2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{u.name}
|
{user.name}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: "10px", color: tokens.muted2 }}>
|
<div style={{ fontSize: "10px", color: tokens.muted2 }}>
|
||||||
{u.email}
|
{user.email}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={menuItemDisabledStyle} aria-disabled="true">
|
<div className="tnav-menuitem" style={menuItemStyle}>
|
||||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
||||||
<circle
|
<circle
|
||||||
cx="7.5"
|
cx="7.5"
|
||||||
|
|
@ -381,7 +347,7 @@ export default function TopNav({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={menuItemDisabledStyle} aria-disabled="true">
|
<div className="tnav-menuitem" style={menuItemStyle}>
|
||||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
||||||
<circle
|
<circle
|
||||||
cx="7.5"
|
cx="7.5"
|
||||||
|
|
@ -399,7 +365,7 @@ export default function TopNav({
|
||||||
Настройки
|
Настройки
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={menuItemDisabledStyle} aria-disabled="true">
|
<div className="tnav-menuitem" style={menuItemStyle}>
|
||||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
||||||
<circle
|
<circle
|
||||||
cx="7.5"
|
cx="7.5"
|
||||||
|
|
@ -426,10 +392,7 @@ export default function TopNav({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => setUserOpen(false)}
|
||||||
setUserOpen(false);
|
|
||||||
onLogout?.();
|
|
||||||
}}
|
|
||||||
className="tnav-logout"
|
className="tnav-logout"
|
||||||
style={{ ...menuItemStyle, color: tokens.danger }}
|
style={{ ...menuItemStyle, color: tokens.danger }}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -651,83 +651,6 @@ export function mapObject(e: AggregatedEstimate): ObjectInfo {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ParamsPanel map markers (analog price pins) ─────────────────────────────
|
|
||||||
// Finding #2: the 01 map showed STATIC fixture price pins (12,7 / 7,6 / 22,4 млн)
|
|
||||||
// that never reflected the real estimate. We project each analog with a known
|
|
||||||
// {lat,lon} relative to the subject (target_lat/lon) into the map's 0–100% box.
|
|
||||||
// There is no real basemap/zoom behind the SVG, so a fixed geographic half-span
|
|
||||||
// (~1.2 km) is used purely to spread nearby analogs; out-of-box analogs clamp to
|
|
||||||
// the 4–96% margin. Closest ~6 by distance are shown (the pin chrome only fits a
|
|
||||||
// handful). null / empty / un-geocoded estimate → [] (the map shows just the
|
|
||||||
// subject pin, never the fake fixture prices).
|
|
||||||
|
|
||||||
/** One analog price pin positioned by CSS % offsets over the ParamsPanel map. */
|
|
||||||
export interface MapMarker {
|
|
||||||
left: string; // e.g. "62%"
|
|
||||||
top: string; // e.g. "31%"
|
|
||||||
label: string; // "12,70 млн ₽"
|
|
||||||
sub: string; // "54 м²"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Half-span from the subject to a box edge (the 50% multiplier). ≈ 0.011° lat /
|
|
||||||
// 0.018° lon ≈ ~1.2 km at ЕКБ latitude (cos 56.8° ≈ 0.55). Tuneable: larger pulls
|
|
||||||
// analogs toward the centre, smaller pushes them to the clamped margins.
|
|
||||||
const MAP_LAT_HALF_SPAN = 0.011;
|
|
||||||
const MAP_LON_HALF_SPAN = 0.018;
|
|
||||||
const MAP_MARKER_CAP = 6;
|
|
||||||
const MAP_CLAMP_LO = 4;
|
|
||||||
const MAP_CLAMP_HI = 96;
|
|
||||||
|
|
||||||
/** Analog price pins for the 01 ParamsPanel map. null/empty/un-geocoded → []. */
|
|
||||||
export function mapMarkers(e: AggregatedEstimate | null): MapMarker[] {
|
|
||||||
if (e == null || e.target_lat == null || e.target_lon == null) return [];
|
|
||||||
const tLat = e.target_lat;
|
|
||||||
const tLon = e.target_lon;
|
|
||||||
|
|
||||||
const geo = e.analogs.filter(
|
|
||||||
(a): a is AnalogLot & { lat: number; lon: number } =>
|
|
||||||
a.lat != null &&
|
|
||||||
a.lon != null &&
|
|
||||||
Number.isFinite(a.lat) &&
|
|
||||||
Number.isFinite(a.lon),
|
|
||||||
);
|
|
||||||
if (geo.length === 0) return [];
|
|
||||||
|
|
||||||
// Closest first: real distance_m when known, else a degree-based estimate
|
|
||||||
// (lon scaled by cos≈0.55) so analogs without distance_m still order sensibly.
|
|
||||||
const ranked = geo
|
|
||||||
.map((a) => {
|
|
||||||
const fallbackM =
|
|
||||||
Math.hypot(a.lat - tLat, (a.lon - tLon) * 0.55) * 111_320;
|
|
||||||
const dist =
|
|
||||||
a.distance_m != null && Number.isFinite(a.distance_m)
|
|
||||||
? a.distance_m
|
|
||||||
: fallbackM;
|
|
||||||
return { a, dist };
|
|
||||||
})
|
|
||||||
.sort((x, y) => x.dist - y.dist)
|
|
||||||
.slice(0, MAP_MARKER_CAP);
|
|
||||||
|
|
||||||
return ranked.map(({ a }) => {
|
|
||||||
const left = clamp(
|
|
||||||
50 + ((a.lon - tLon) / MAP_LON_HALF_SPAN) * 50,
|
|
||||||
MAP_CLAMP_LO,
|
|
||||||
MAP_CLAMP_HI,
|
|
||||||
);
|
|
||||||
const top = clamp(
|
|
||||||
50 - ((a.lat - tLat) / MAP_LAT_HALF_SPAN) * 50,
|
|
||||||
MAP_CLAMP_LO,
|
|
||||||
MAP_CLAMP_HI,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
left: `${round1(left)}%`,
|
|
||||||
top: `${round1(top)}%`,
|
|
||||||
label: `${fmtMln(a.price_rub)} млн ₽`,
|
|
||||||
sub: Number.isFinite(a.area_m2) ? `${Math.round(a.area_m2)} м²` : "—",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */
|
/** Full 02 РЕЗУЛЬТАТ block: 3 cards + meta + ranges + scatter + sources. */
|
||||||
export function mapResultPanel(
|
export function mapResultPanel(
|
||||||
e: AggregatedEstimate,
|
e: AggregatedEstimate,
|
||||||
|
|
@ -842,17 +765,13 @@ export function mapSummary(
|
||||||
const anaBargain = kpi?.median_bargain_pct ?? null;
|
const anaBargain = kpi?.median_bargain_pct ?? null;
|
||||||
const row4Parts = [
|
const row4Parts = [
|
||||||
anaExp != null ? `${anaExp} дн` : null,
|
anaExp != null ? `${anaExp} дн` : null,
|
||||||
// Finding #10: negate the bargain so a price reduction reads "−3%" (compact
|
anaBargain != null ? fmtPct(anaBargain) : null,
|
||||||
// whole-percent here), not a misleading "+3%". See bargainSigned() rationale.
|
|
||||||
anaBargain != null ? fmtPct(-anaBargain) : null,
|
|
||||||
].filter((p): p is string => p != null);
|
].filter((p): p is string => p != null);
|
||||||
const row4Value = row4Parts.length > 0 ? row4Parts.join(" · ") : "—";
|
const row4Value = row4Parts.length > 0 ? row4Parts.join(" · ") : "—";
|
||||||
|
|
||||||
const rows: SummaryRow[] = [
|
const rows: SummaryRow[] = [
|
||||||
{
|
{
|
||||||
// Finding #9: fed by house-analytics radius data (район, radius_m up to
|
label: "Продажи в доме",
|
||||||
// 300 m), NOT strictly the exact house → honest label "Продажи рядом".
|
|
||||||
label: "Продажи рядом",
|
|
||||||
value: row1Value,
|
value: row1Value,
|
||||||
dot: houseSold && houseSold > 0 ? "accent" : "muted",
|
dot: houseSold && houseSold > 0 ? "accent" : "muted",
|
||||||
nav: 1,
|
nav: 1,
|
||||||
|
|
@ -949,22 +868,6 @@ function pct1(v: number | null | undefined): string {
|
||||||
return `${sign}${Math.abs(r).toFixed(1)}%`;
|
return `${sign}${Math.abs(r).toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Finding #10 — "СРЕДНИЙ ТОРГ" rendering. The house-analytics KPI
|
|
||||||
* median_bargain_pct is the median over house_placement_history of
|
|
||||||
* (start_price − last_price) / start_price × 100
|
|
||||||
* (see backend trade_in.py), so a POSITIVE value means the asking price was
|
|
||||||
* REDUCED from its initial to its final value (the haggle / discount room).
|
|
||||||
* Feeding that straight through pct1 renders a misleading "+3.3%" — the "+"
|
|
||||||
* reads as a price INCREASE. We instead show the SIGNED change from initial →
|
|
||||||
* final price, so a reduction reads "−3.3%", matching the tile sub-label
|
|
||||||
* "от начальной до итоговой цены" (final price is 3.3% below initial). null → "—".
|
|
||||||
*/
|
|
||||||
function bargainSigned(pct: number | null | undefined): string {
|
|
||||||
if (pct == null || !Number.isFinite(pct)) return "—";
|
|
||||||
return pct1(-pct);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Distance label: <1km -> "850 м", else "1.2 км". null/NaN -> "—". */
|
/** Distance label: <1km -> "850 м", else "1.2 км". null/NaN -> "—". */
|
||||||
function fmtDist(m: number | null | undefined): string {
|
function fmtDist(m: number | null | undefined): string {
|
||||||
if (m == null || !Number.isFinite(m)) return "—";
|
if (m == null || !Number.isFinite(m)) return "—";
|
||||||
|
|
@ -1039,19 +942,6 @@ function dealMeta(l: AnalogLot): string {
|
||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Finding #7 — de-glue addresses. Some geocoded analog/deal addresses run a
|
|
||||||
* metro/word straight onto the house number ("…, 69/2Геологическая"). Insert a
|
|
||||||
* space where a digit is immediately followed by the start of a Cyrillic WORD
|
|
||||||
* (capital + lowercase). NB: deliberately NOT the looser /(\d)([А-ЯЁ])/ — that
|
|
||||||
* would also split legitimate single-letter house litereras (14А, 5Б, д.5К),
|
|
||||||
* which are valid ЕКБ house numbers. Pure display cleanup; never touches the
|
|
||||||
* comma-splitting parseAddress.
|
|
||||||
*/
|
|
||||||
function deglueAddr(addr: string): string {
|
|
||||||
return addr.replace(/(\d)([А-ЯЁ][а-яё])/g, "$1 $2");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 04 ПРОДАЖИ В ДОМЕ (HistoryView) ─────────────────────────────────────────
|
// ── 04 ПРОДАЖИ В ДОМЕ (HistoryView) ─────────────────────────────────────────
|
||||||
|
|
||||||
/** ДКП deal row + real per-row address/date/listing-source (DkpRow has none). */
|
/** ДКП deal row + real per-row address/date/listing-source (DkpRow has none). */
|
||||||
|
|
@ -1140,7 +1030,7 @@ export function mapHistory(
|
||||||
ask,
|
ask,
|
||||||
delta: pct1(pr.discount_pct),
|
delta: pct1(pr.discount_pct),
|
||||||
deltaColor,
|
deltaColor,
|
||||||
addr: pr.deal_address ? deglueAddr(pr.deal_address) : undefined,
|
addr: pr.deal_address || undefined,
|
||||||
date: fmtDate(pr.deal_date),
|
date: fmtDate(pr.deal_date),
|
||||||
source: pr.listing_source,
|
source: pr.listing_source,
|
||||||
};
|
};
|
||||||
|
|
@ -1166,8 +1056,7 @@ function buildAnalyticsKpis(k: HouseAnalyticsKpi | null): AnalyticsKpi[] {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "СРЕДНИЙ ТОРГ",
|
label: "СРЕДНИЙ ТОРГ",
|
||||||
// Finding #10: negate so a price reduction reads "−3.3%" not "+3.3%".
|
value: pct1(k?.median_bargain_pct),
|
||||||
value: bargainSigned(k?.median_bargain_pct),
|
|
||||||
color: bargainColor,
|
color: bargainColor,
|
||||||
sub: "от начальной до итоговой цены",
|
sub: "от начальной до итоговой цены",
|
||||||
},
|
},
|
||||||
|
|
@ -1509,7 +1398,7 @@ export function mapSources(
|
||||||
const e = estimate;
|
const e = estimate;
|
||||||
|
|
||||||
const adRows: AdRowData[] = (e?.analogs ?? []).map((l) => ({
|
const adRows: AdRowData[] = (e?.analogs ?? []).map((l) => ({
|
||||||
addr: deglueAddr(l.address || "—"),
|
addr: l.address || "—",
|
||||||
meta: adMeta(l),
|
meta: adMeta(l),
|
||||||
ppm: numRu(l.price_per_m2),
|
ppm: numRu(l.price_per_m2),
|
||||||
price: numRu(l.price_rub),
|
price: numRu(l.price_rub),
|
||||||
|
|
@ -1519,7 +1408,7 @@ export function mapSources(
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const dealRows: DealRowData[] = (e?.actual_deals ?? []).map((l) => ({
|
const dealRows: DealRowData[] = (e?.actual_deals ?? []).map((l) => ({
|
||||||
addr: deglueAddr(l.address || "—"),
|
addr: l.address || "—",
|
||||||
meta: dealMeta(l),
|
meta: dealMeta(l),
|
||||||
ppm: numRu(l.price_per_m2),
|
ppm: numRu(l.price_per_m2),
|
||||||
price: numRu(l.price_rub),
|
price: numRu(l.price_rub),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue