Merge pull request 'fix(sf-11): Top layouts — sales period column + drill-in expand' (#285) from fix/sf-11-sales-period-drill-in into main
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 4m20s
Deploy / deploy (push) Successful in 49s

This commit is contained in:
lekss361 2026-05-17 13:19:24 +00:00
commit eb865a08bc
3 changed files with 264 additions and 98 deletions

View file

@ -32,7 +32,7 @@
"openapi-typescript": "^7.0.0",
"postcss": "^8.4.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.0"
"typescript": "5.9.3"
}
},
"node_modules/@alloc/quick-lru": {

View file

@ -35,6 +35,6 @@
"openapi-typescript": "^7.0.0",
"postcss": "^8.4.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.0"
"typescript": "5.9.3"
}
}

View file

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { Fragment, useState } from "react";
import { useBestLayouts } from "@/hooks/useBestLayouts";
import { API_BASE_URL } from "@/lib/api";
import type {
@ -60,7 +60,88 @@ function DataQualityCard({ dq }: { dq: BestLayoutsResponse["data_quality"] }) {
);
}
const COMPETITOR_PREVIEW_LIMIT = 5;
function salesPeriodMonths(supply: number, velocity: number): number | null {
if (velocity <= 0) return null;
return Math.ceil(supply / velocity);
}
function SalesPeriodCell({ months }: { months: number | null }) {
if (months === null) return <span style={{ color: "#9ca3af" }}></span>;
if (months < 12)
return (
<span className="text-emerald-600 font-semibold tabular-nums">
{months} мес
</span>
);
if (months <= 24)
return <span className="text-slate-600 tabular-nums">{months} мес</span>;
return <span className="text-amber-600 tabular-nums">{months} мес</span>;
}
function CompetitorDrillIn({
ids,
colSpan,
}: {
ids: number[];
colSpan: number;
}) {
const [showAll, setShowAll] = useState(false);
const visible = showAll ? ids : ids.slice(0, COMPETITOR_PREVIEW_LIMIT);
const hidden = ids.length - COMPETITOR_PREVIEW_LIMIT;
return (
<tr style={{ background: "#f0f4ff" }}>
<td colSpan={colSpan} style={{ padding: "8px 16px" }}>
<div
style={{
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 6,
fontSize: 11,
}}
>
<span style={{ color: "#6b7280", fontWeight: 600, marginRight: 4 }}>
ЖК-источники:
</span>
{visible.map((id) => (
<a
key={id}
href={`https://наш.дом.рф/services/catalog-newbuildings/object/${id}`}
target="_blank"
rel="noopener noreferrer"
className="bg-blue-100 text-blue-700 rounded px-2 py-0.5 font-mono hover:bg-blue-200 transition-colors"
>
{id}
</a>
))}
{!showAll && hidden > 0 && (
<button
onClick={() => setShowAll(true)}
className="text-blue-600 underline text-[11px] cursor-pointer bg-transparent border-none p-0"
>
и ещё {hidden}
</button>
)}
{showAll && ids.length > COMPETITOR_PREVIEW_LIMIT && (
<button
onClick={() => setShowAll(false)}
className="text-gray-400 underline text-[11px] cursor-pointer bg-transparent border-none p-0"
>
свернуть
</button>
)}
</div>
</td>
</tr>
);
}
function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
if (rows.length === 0) {
return (
<div className="text-gray-400 text-[13px] py-3">
@ -69,6 +150,20 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
);
}
function toggleRow(signature: string) {
setExpandedRows((prev) => {
const next = new Set(prev);
if (next.has(signature)) {
next.delete(signature);
} else {
next.add(signature);
}
return next;
});
}
const TOTAL_COLS = 9; // # Тип Площадь Скорость СрПлощадь СрЦена СрокПродажи Продано Chevron
const headers = [
"#",
"Тип",
@ -76,7 +171,9 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
"Скорость / мес",
"Средн. площадь, м²",
"Средн. цена, ₽/м²",
"Срок продажи (мес)",
"Продано, %",
"",
];
return (
@ -90,9 +187,9 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
>
<thead>
<tr style={{ background: "#f6f7f9" }}>
{headers.map((h) => (
{headers.map((h, idx) => (
<th
key={h}
key={idx}
className="px-3 py-2 text-left border-b border-gray-200 font-semibold text-gray-700 whitespace-nowrap"
>
{h}
@ -101,109 +198,178 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr
key={row.signature}
style={{
background: i % 2 === 0 ? "#fff" : "#fafbfc",
borderBottom: "1px solid #f3f4f6",
}}
>
<td
style={{
padding: "7px 12px",
fontWeight: 700,
color: "#1d4ed8",
fontVariantNumeric: "tabular-nums",
}}
>
{row.rank}
</td>
<td
style={{
padding: "7px 12px",
fontWeight: 500,
color: "#111827",
}}
>
{ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket}
</td>
<td style={{ padding: "7px 12px", color: "#374151" }}>
{row.area_bin} м²
</td>
<td
style={{
padding: "7px 12px",
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{row.velocity_per_month.toFixed(2)}
</td>
<td
style={{
padding: "7px 12px",
textAlign: "right",
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{row.avg_area_m2.toFixed(1)}
</td>
<td
style={{
padding: "7px 12px",
textAlign: "right",
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{row.avg_price_per_m2_rub != null
? Math.round(row.avg_price_per_m2_rub).toLocaleString(
"ru-RU",
)
: "—"}
</td>
<td
style={{
padding: "7px 12px",
textAlign: "right",
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{row.sold_pct_of_supply != null ? (
<span
{rows.map((row, i) => {
const isExpanded = expandedRows.has(row.signature);
const months = salesPeriodMonths(
row.supply_units_in_radius,
row.velocity_per_month,
);
const hasCompetitors = row.competitor_obj_ids.length > 0;
return (
<Fragment key={row.signature}>
<tr
onClick={() => {
if (hasCompetitors) toggleRow(row.signature);
}}
onKeyDown={(e) => {
if (
hasCompetitors &&
(e.key === "Enter" || e.key === " ")
) {
e.preventDefault();
toggleRow(row.signature);
}
}}
tabIndex={hasCompetitors ? 0 : undefined}
role={hasCompetitors ? "button" : undefined}
aria-expanded={hasCompetitors ? isExpanded : undefined}
style={{
background: i % 2 === 0 ? "#fff" : "#fafbfc",
borderBottom: isExpanded ? "none" : "1px solid #f3f4f6",
cursor: hasCompetitors ? "pointer" : "default",
}}
title={
hasCompetitors
? "Нажмите, чтобы увидеть ЖК-источники"
: undefined
}
>
<td
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "7px 12px",
fontWeight: 700,
color: "#1d4ed8",
fontVariantNumeric: "tabular-nums",
}}
>
{`${(row.sold_pct_of_supply ?? 0).toFixed(0)}%`}
{row.is_oversold && (
{row.rank}
</td>
<td
style={{
padding: "7px 12px",
fontWeight: 500,
color: "#111827",
}}
>
{ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket}
</td>
<td style={{ padding: "7px 12px", color: "#374151" }}>
{row.area_bin} м²
</td>
<td
style={{
padding: "7px 12px",
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{row.velocity_per_month.toFixed(2)}
</td>
<td
style={{
padding: "7px 12px",
textAlign: "right",
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{row.avg_area_m2.toFixed(1)}
</td>
<td
style={{
padding: "7px 12px",
textAlign: "right",
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{row.avg_price_per_m2_rub != null
? Math.round(row.avg_price_per_m2_rub).toLocaleString(
"ru-RU",
)
: "—"}
</td>
<td
style={{
padding: "7px 12px",
textAlign: "right",
}}
>
<SalesPeriodCell months={months} />
</td>
<td
style={{
padding: "7px 12px",
textAlign: "right",
color: "#374151",
fontVariantNumeric: "tabular-nums",
}}
>
{row.sold_pct_of_supply != null ? (
<span
style={{
background: "var(--warn-soft)",
color: "var(--warn)",
fontSize: 10,
fontWeight: 600,
padding: "1px 5px",
borderRadius: 4,
whiteSpace: "nowrap",
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
title="Сделки за 24 мес превышают текущий snapshot supply — значение обрезано до 100%"
>
&gt;100%
{`${(row.sold_pct_of_supply ?? 0).toFixed(0)}%`}
{row.is_oversold && (
<span
style={{
background: "var(--warn-soft)",
color: "var(--warn)",
fontSize: 10,
fontWeight: 600,
padding: "1px 5px",
borderRadius: 4,
whiteSpace: "nowrap",
}}
title="Сделки за 24 мес превышают текущий snapshot supply — значение обрезано до 100%"
>
&gt;100%
</span>
)}
</span>
) : (
"—"
)}
</td>
<td
style={{
padding: "7px 10px",
color: "#9ca3af",
width: 24,
}}
>
{hasCompetitors && (
<span
style={{
display: "inline-block",
transform: isExpanded
? "rotate(90deg)"
: "rotate(0deg)",
transition: "transform 0.15s ease",
fontSize: 14,
lineHeight: 1,
userSelect: "none",
}}
>
</span>
)}
</span>
) : (
"—"
</td>
</tr>
{isExpanded && hasCompetitors && (
<CompetitorDrillIn
ids={row.competitor_obj_ids}
colSpan={TOTAL_COLS}
/>
)}
</td>
</tr>
))}
</Fragment>
);
})}
</tbody>
</table>
</div>