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 { ListingsCard } from "@/components/trade-in/ListingsCard";
|
||||
import { DealsCard } from "@/components/trade-in/DealsCard";
|
||||
import { StreetDealsCard } from "@/components/trade-in/StreetDealsCard";
|
||||
import { OfferCard } from "@/components/trade-in/OfferCard";
|
||||
import { TestPresets } from "@/components/trade-in/TestPresets";
|
||||
import { useEstimateImvBenchmark, useEstimateHouses } from "@/lib/trade-in-api";
|
||||
|
|
@ -182,6 +183,7 @@ export default function TradeInPage() {
|
|||
<PhotoUpload estimateId={resultData.estimate.estimate_id} />
|
||||
<ListingsCard estimate={resultData.estimate} estimateId={currentEstimateId ?? undefined} />
|
||||
<DealsCard estimate={resultData.estimate} />
|
||||
<StreetDealsCard 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,
|
||||
PlacementHistoryItem,
|
||||
SellTimeSensitivityResponse,
|
||||
StreetDealsResponse,
|
||||
TradeInEstimateInput,
|
||||
} 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
|
||||
* Median exposure days bucketed by price premium (-5%, 0, +5%, +10%).
|
||||
|
|
|
|||
|
|
@ -192,6 +192,20 @@ export interface HouseAnalyticsResponse {
|
|||
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) ──
|
||||
|
||||
export interface SellTimeBucket {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue