All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m35s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 37s
Co-authored-by: bot-frontend <bot-frontend@gendsgn.local> Co-committed-by: bot-frontend <bot-frontend@gendsgn.local>
290 lines
10 KiB
TypeScript
290 lines
10 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* StreetDealsCard — «По вашей улице» — ДКП-сделки Росреестра по улице target адреса
|
||
* с привязкой к историческим listings (Avito / Cian / Yandex / N1).
|
||
*
|
||
* Питается от GET /api/v1/trade-in/sales-vs-listings (PR L / #564 Phase 2).
|
||
* Endpoint = superset предыдущего /street-deals: возвращает все pairs (LEFT JOIN
|
||
* deal → listing) + linkage_rate. Aggregates (count / median / range) считаются
|
||
* client-side по pairs[].deal_price_rub.
|
||
*
|
||
* Не рендерится если street не извлёкся или pairs.length = 0.
|
||
*/
|
||
import { useSalesVsListings } from "@/lib/trade-in-api";
|
||
import { openRosreestrWithAddress } from "@/lib/rosreestr";
|
||
import { safeUrl } from "@/lib/safeUrl";
|
||
import type { AggregatedEstimate, SalesListingPair } 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 "—";
|
||
}
|
||
}
|
||
|
||
/** Median по числовому массиву (linear interpolation between centre items). */
|
||
function median(values: number[]): number {
|
||
if (values.length === 0) return 0;
|
||
const sorted = [...values].sort((a, b) => a - b);
|
||
const mid = sorted.length / 2;
|
||
if (Number.isInteger(mid)) {
|
||
return (sorted[mid - 1] + sorted[mid]) / 2;
|
||
}
|
||
return sorted[Math.floor(mid)];
|
||
}
|
||
|
||
/** Срок «за N дней до» / «через N дней после» сделки. */
|
||
function fmtDaysToDeal(days: number | null): string {
|
||
if (days === null) return "—";
|
||
const abs = Math.abs(days);
|
||
// Положительное = listing раньше сделки (типичный случай: «за 45 дней до»).
|
||
// Отрицательное = listing появился позже сделки (отложенный парсинг, rare).
|
||
return days >= 0 ? `за ${abs} дн до` : `через ${abs} дн после`;
|
||
}
|
||
|
||
/** Формат discount_pct со знаком и одной десятичной. */
|
||
function fmtDiscount(pct: number): string {
|
||
const sign = pct > 0 ? "+" : pct < 0 ? "−" : "";
|
||
return `${sign}${Math.abs(pct).toFixed(1)}%`;
|
||
}
|
||
|
||
export function StreetDealsCard({ estimate }: Props) {
|
||
const { data, isLoading, isError } = useSalesVsListings(
|
||
estimate.target_address ?? null,
|
||
estimate.area_m2 ?? null,
|
||
estimate.rooms ?? null,
|
||
);
|
||
|
||
if (isLoading || isError) return null;
|
||
if (!data || !data.street || data.pairs.length === 0) return null;
|
||
|
||
// Client-side aggregates по deal_price_rub / deal_price_per_m2 — endpoint
|
||
// /sales-vs-listings не возвращает их (только linkage-связанные).
|
||
const prices = data.pairs.map((p) => p.deal_price_rub);
|
||
const ppm2Values = data.pairs.map((p) => p.deal_price_per_m2);
|
||
const medianPpm2 = Math.round(median(ppm2Values));
|
||
const rangeLow = Math.min(...prices);
|
||
const rangeHigh = Math.max(...prices);
|
||
const rangeLowM = (rangeLow / 1_000_000).toFixed(1);
|
||
const rangeHighM = (rangeHigh / 1_000_000).toFixed(1);
|
||
|
||
const hasAnyListing = data.deals_with_listings > 0;
|
||
const visiblePairs: SalesListingPair[] = data.pairs.slice(0, 10);
|
||
|
||
return (
|
||
<article className="card">
|
||
<div className="card-head">
|
||
<div>
|
||
<div className="section-kicker">Секция · По вашей улице</div>
|
||
<h2>ДКП-сделки на улице {data.street}</h2>
|
||
{hasAnyListing && (
|
||
<div className="linkage-hint">
|
||
<b className="mono">{data.linkage_rate_pct.toFixed(0)}%</b> сделок имеют
|
||
исторический ASK
|
||
{data.median_discount_pct !== null && (
|
||
<>
|
||
{" · медианный торг "}
|
||
<b
|
||
className={
|
||
data.median_discount_pct < 0
|
||
? "discount-neg mono"
|
||
: data.median_discount_pct > 0
|
||
? "discount-pos mono"
|
||
: "mono"
|
||
}
|
||
>
|
||
{fmtDiscount(data.median_discount_pct)}
|
||
</b>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
{data.data_quality === "street_only" && (
|
||
<div className="linkage-hint">
|
||
Данные по улице, не по конкретному дому: привязать сделки ДКП к
|
||
отдельному объявлению по этому адресу нельзя — показаны все сделки
|
||
на улице.
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="card-meta">
|
||
<div>
|
||
Период: <b className="mono">{data.period_months} мес</b>
|
||
</div>
|
||
<div style={{ marginTop: 4 }}>источник: Росреестр + объявления</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="count-strip">
|
||
<div className="count-cell">
|
||
<div className="label">Сделок за {data.period_months} мес</div>
|
||
<div className="value">
|
||
<span data-tnum>{data.total_deals}</span>
|
||
<span className="unit">шт</span>
|
||
</div>
|
||
</div>
|
||
<div className="count-cell">
|
||
<div className="label">Медиана</div>
|
||
<div className="value">
|
||
<span data-tnum>{fmtRub(medianPpm2)}</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 dt-with-asks" aria-label="Сделки по улице с историческим ASK">
|
||
<thead>
|
||
<tr>
|
||
<th>Адрес</th>
|
||
<th className="num">Площадь</th>
|
||
<th className="num">Цена сделки</th>
|
||
<th className="num">₽/м²</th>
|
||
<th className="num">Дата</th>
|
||
{hasAnyListing && <th>Объявление до сделки</th>}
|
||
<th><span className="sr-only">Действия</span></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{visiblePairs.map((p) => {
|
||
const safeListingUrl = safeUrl(p.listing_source_url);
|
||
const hasListing = p.listing_id !== null && p.listing_price_rub !== null;
|
||
return (
|
||
<tr key={p.deal_id}>
|
||
<td>
|
||
<span className="addr">
|
||
<span className="a-main">{p.deal_address}</span>
|
||
</span>
|
||
</td>
|
||
<td className="num">{p.deal_area_m2.toFixed(1)} м²</td>
|
||
<td className="num">{fmtRub(p.deal_price_rub)} ₽</td>
|
||
<td className="num">{fmtRub(p.deal_price_per_m2)}</td>
|
||
<td className="num">{fmtDate(p.deal_date)}</td>
|
||
{hasAnyListing && (
|
||
<td className="listing-cell">
|
||
{hasListing ? (
|
||
<ListingBadge
|
||
source={p.listing_source}
|
||
url={safeListingUrl}
|
||
priceRub={p.listing_price_rub}
|
||
daysToDeal={p.days_listing_to_deal}
|
||
discountPct={p.discount_pct}
|
||
/>
|
||
) : (
|
||
<span className="listing-empty">—</span>
|
||
)}
|
||
</td>
|
||
)}
|
||
<td>
|
||
<button
|
||
type="button"
|
||
className="rosreestr-btn"
|
||
onClick={() => void openRosreestrWithAddress(p.deal_address)}
|
||
title="Скопировать адрес и открыть форму запроса выписки ЕГРН"
|
||
>
|
||
Росреестр ↗
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
|
||
<div className="table-foot">
|
||
<span>
|
||
Показано{" "}
|
||
<b style={{ color: "var(--fg)" }}>{visiblePairs.length}</b> из{" "}
|
||
<b style={{ color: "var(--fg)" }}>{data.total_deals}</b> сделок
|
||
</span>
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
// ── Listing badge cell ───────────────────────────────────────────────────────
|
||
|
||
interface ListingBadgeProps {
|
||
source: string | null;
|
||
url: string | null;
|
||
priceRub: number | null;
|
||
daysToDeal: number | null;
|
||
discountPct: number | null;
|
||
}
|
||
|
||
function ListingBadge({
|
||
source,
|
||
url,
|
||
priceRub,
|
||
daysToDeal,
|
||
discountPct,
|
||
}: ListingBadgeProps) {
|
||
const sourceLabel = source ? source.toUpperCase() : "—";
|
||
// discount < 0 → продано дешевле выставленного (типичный торг, success).
|
||
// discount > 0 → продано дороже выставленного (редко, обычно ошибка data, danger).
|
||
const discountClass =
|
||
discountPct === null
|
||
? ""
|
||
: discountPct < 0
|
||
? "discount-neg"
|
||
: discountPct > 0
|
||
? "discount-pos"
|
||
: "";
|
||
|
||
const inner = (
|
||
<>
|
||
<span className="listing-source-badge">{sourceLabel}</span>
|
||
<span className="listing-price mono">
|
||
{priceRub !== null ? `${fmtRub(priceRub)} ₽` : "—"}
|
||
</span>
|
||
<span className="listing-meta">
|
||
{fmtDaysToDeal(daysToDeal)}
|
||
{discountPct !== null && (
|
||
<>
|
||
{" · "}
|
||
<b className={`${discountClass} mono`}>{fmtDiscount(discountPct)}</b>
|
||
</>
|
||
)}
|
||
</span>
|
||
</>
|
||
);
|
||
|
||
if (url) {
|
||
return (
|
||
<a
|
||
className="listing-link"
|
||
href={url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
title={`Открыть объявление на ${sourceLabel.toLowerCase()}`}
|
||
>
|
||
{inner}
|
||
</a>
|
||
);
|
||
}
|
||
|
||
return <span className="listing-link is-static">{inner}</span>;
|
||
}
|