feat(tradein): «По вашей улице» — StreetDealsCard на real ДКП-сделки Росреестра (#556)
This commit is contained in:
parent
a63e459e25
commit
4d1c409e18
4 changed files with 174 additions and 0 deletions
|
|
@ -23,6 +23,7 @@ import { HouseAnalyticsSection } from "@/components/trade-in/HouseAnalyticsSecti
|
||||||
import { PhotoUpload } from "@/components/trade-in/PhotoUpload";
|
import { PhotoUpload } from "@/components/trade-in/PhotoUpload";
|
||||||
import { ListingsCard } from "@/components/trade-in/ListingsCard";
|
import { ListingsCard } from "@/components/trade-in/ListingsCard";
|
||||||
import { DealsCard } from "@/components/trade-in/DealsCard";
|
import { DealsCard } from "@/components/trade-in/DealsCard";
|
||||||
|
import { StreetDealsCard } from "@/components/trade-in/StreetDealsCard";
|
||||||
import { OfferCard } from "@/components/trade-in/OfferCard";
|
import { OfferCard } from "@/components/trade-in/OfferCard";
|
||||||
import { TestPresets } from "@/components/trade-in/TestPresets";
|
import { TestPresets } from "@/components/trade-in/TestPresets";
|
||||||
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
|
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
|
||||||
|
|
@ -182,6 +183,7 @@ export default function TradeInPage() {
|
||||||
<PhotoUpload estimateId={resultData.estimate.estimate_id} />
|
<PhotoUpload estimateId={resultData.estimate.estimate_id} />
|
||||||
<ListingsCard estimate={resultData.estimate} estimateId={currentEstimateId ?? undefined} />
|
<ListingsCard estimate={resultData.estimate} estimateId={currentEstimateId ?? undefined} />
|
||||||
<DealsCard estimate={resultData.estimate} />
|
<DealsCard estimate={resultData.estimate} />
|
||||||
|
<StreetDealsCard estimate={resultData.estimate} />
|
||||||
<OfferCard estimate={resultData.estimate} />
|
<OfferCard estimate={resultData.estimate} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
134
tradein-mvp/frontend/src/components/trade-in/StreetDealsCard.tsx
Normal file
134
tradein-mvp/frontend/src/components/trade-in/StreetDealsCard.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StreetDealsCard — «По вашей улице» — ДКП-сделки Росреестра по улице target адреса.
|
||||||
|
* Питается от GET /api/v1/trade-in/street-deals (PR #555).
|
||||||
|
* Не рендерится если street не извлёкся или count = 0.
|
||||||
|
*/
|
||||||
|
import { useStreetDeals } from "@/lib/trade-in-api";
|
||||||
|
import { openRosreestrWithAddress } from "@/lib/rosreestr";
|
||||||
|
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
estimate: AggregatedEstimate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtRub(v: number): string {
|
||||||
|
return v.toLocaleString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString("ru-RU", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StreetDealsCard({ estimate }: Props) {
|
||||||
|
const { data, isLoading, isError } = useStreetDeals(
|
||||||
|
estimate.target_address ?? null,
|
||||||
|
estimate.area_m2 ?? null,
|
||||||
|
estimate.rooms ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading || isError) return null;
|
||||||
|
if (!data || !data.street || data.count === 0) return null;
|
||||||
|
|
||||||
|
const rangeLowM = (data.range_low_rub / 1_000_000).toFixed(1);
|
||||||
|
const rangeHighM = (data.range_high_rub / 1_000_000).toFixed(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="card">
|
||||||
|
<div className="card-head">
|
||||||
|
<div>
|
||||||
|
<div className="section-kicker">Секция · По вашей улице</div>
|
||||||
|
<h2>ДКП-сделки на улице {data.street}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="card-meta">
|
||||||
|
<div>
|
||||||
|
Период: <b className="mono">12 мес</b>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 4 }}>источник: Росреестр (открытые данные)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="count-strip">
|
||||||
|
<div className="count-cell">
|
||||||
|
<div className="label">Сделок за 12 мес</div>
|
||||||
|
<div className="value">
|
||||||
|
<span data-tnum>{data.count}</span>
|
||||||
|
<span className="unit">шт</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="count-cell">
|
||||||
|
<div className="label">Медиана</div>
|
||||||
|
<div className="value">
|
||||||
|
<span data-tnum>{fmtRub(data.median_price_per_m2)}</span>
|
||||||
|
<span className="unit">₽/м²</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="count-cell">
|
||||||
|
<div className="label">Диапазон</div>
|
||||||
|
<div className="value">
|
||||||
|
<span data-tnum>
|
||||||
|
{rangeLowM} – {rangeHighM}
|
||||||
|
</span>
|
||||||
|
<span className="unit">млн ₽</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="dt" aria-label="Сделки по улице">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Адрес</th>
|
||||||
|
<th className="num">Площадь</th>
|
||||||
|
<th className="num">Цена</th>
|
||||||
|
<th className="num">₽/м²</th>
|
||||||
|
<th className="num">Дата</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.deals.map((d: AnalogLot, i: number) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>
|
||||||
|
<span className="addr">
|
||||||
|
<span className="a-main">{d.address}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="num">{d.area_m2.toFixed(1)} м²</td>
|
||||||
|
<td className="num">{fmtRub(d.price_rub)} ₽</td>
|
||||||
|
<td className="num">{fmtRub(d.price_per_m2)}</td>
|
||||||
|
<td className="num">{fmtDate(d.listing_date)}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rosreestr-btn"
|
||||||
|
onClick={() => void openRosreestrWithAddress(d.address)}
|
||||||
|
title="Скопировать адрес и открыть форму запроса выписки ЕГРН"
|
||||||
|
>
|
||||||
|
Росреестр ↗
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="table-foot">
|
||||||
|
<span>
|
||||||
|
Показано{" "}
|
||||||
|
<b style={{ color: "var(--fg)" }}>{Math.min(data.deals.length, 10)}</b>{" "}
|
||||||
|
из <b style={{ color: "var(--fg)" }}>{data.count}</b> сделок
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import type {
|
||||||
IMVBenchmarkResponse,
|
IMVBenchmarkResponse,
|
||||||
PlacementHistoryItem,
|
PlacementHistoryItem,
|
||||||
SellTimeSensitivityResponse,
|
SellTimeSensitivityResponse,
|
||||||
|
StreetDealsResponse,
|
||||||
TradeInEstimateInput,
|
TradeInEstimateInput,
|
||||||
} from "@/types/trade-in";
|
} from "@/types/trade-in";
|
||||||
|
|
||||||
|
|
@ -120,6 +121,29 @@ export function useEstimateHouseAnalytics(estimate_id: string | null) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/trade-in/street-deals
|
||||||
|
* ДКП-сделки Росреестра по улице target адреса. Per-street (open dataset
|
||||||
|
* без номера дома). Empty response если street не извлёкся.
|
||||||
|
*/
|
||||||
|
export function useStreetDeals(
|
||||||
|
address: string | null,
|
||||||
|
area_m2: number | null,
|
||||||
|
rooms: number | null,
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (address) params.set("address", address);
|
||||||
|
if (area_m2 !== null) params.set("area_m2", String(area_m2));
|
||||||
|
if (rooms !== null) params.set("rooms", String(rooms));
|
||||||
|
return useQuery<StreetDealsResponse>({
|
||||||
|
queryKey: ["trade-in", "street-deals", address, area_m2, rooms],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<StreetDealsResponse>(`${BASE}/street-deals?${params}`),
|
||||||
|
enabled: !!address && area_m2 !== null && rooms !== null,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/trade-in/estimate/{id}/sell-time-sensitivity
|
* GET /api/v1/trade-in/estimate/{id}/sell-time-sensitivity
|
||||||
* Median exposure days bucketed by price premium (-5%, 0, +5%, +10%).
|
* Median exposure days bucketed by price premium (-5%, 0, +5%, +10%).
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,20 @@ export interface HouseAnalyticsResponse {
|
||||||
kpi: HouseAnalyticsKpi;
|
kpi: HouseAnalyticsKpi;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Street deals (endpoint: GET /trade-in/street-deals) ──
|
||||||
|
|
||||||
|
export interface StreetDealsResponse {
|
||||||
|
street: string | null; // "Космонавтов" / null если не извлёкся
|
||||||
|
period_from: string; // ISO date "2025-05-29"
|
||||||
|
period_to: string;
|
||||||
|
count: number; // all matching, не топ-10
|
||||||
|
median_price_rub: number;
|
||||||
|
median_price_per_m2: number;
|
||||||
|
range_low_rub: number;
|
||||||
|
range_high_rub: number;
|
||||||
|
deals: AnalogLot[]; // top-10 по deal_date DESC
|
||||||
|
}
|
||||||
|
|
||||||
// ── Sell-time sensitivity (endpoint: GET /estimate/{id}/sell-time-sensitivity) ──
|
// ── Sell-time sensitivity (endpoint: GET /estimate/{id}/sell-time-sensitivity) ──
|
||||||
|
|
||||||
export interface SellTimeBucket {
|
export interface SellTimeBucket {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue