fix(sf-11): Top layouts — sales period column + drill-in expand #285

Merged
lekss361 merged 2 commits from fix/sf-11-sales-period-drill-in into main 2026-05-17 13:19:25 +00:00
3 changed files with 264 additions and 98 deletions

View file

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

View file

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

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { Fragment, useState } from "react";
import { useBestLayouts } from "@/hooks/useBestLayouts"; import { useBestLayouts } from "@/hooks/useBestLayouts";
import { API_BASE_URL } from "@/lib/api"; import { API_BASE_URL } from "@/lib/api";
import type { 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[] }) { function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
if (rows.length === 0) { if (rows.length === 0) {
return ( return (
<div className="text-gray-400 text-[13px] py-3"> <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 = [ const headers = [
"#", "#",
"Тип", "Тип",
@ -76,7 +171,9 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
"Скорость / мес", "Скорость / мес",
"Средн. площадь, м²", "Средн. площадь, м²",
"Средн. цена, ₽/м²", "Средн. цена, ₽/м²",
"Срок продажи (мес)",
"Продано, %", "Продано, %",
"",
]; ];
return ( return (
@ -90,9 +187,9 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
> >
<thead> <thead>
<tr style={{ background: "#f6f7f9" }}> <tr style={{ background: "#f6f7f9" }}>
{headers.map((h) => ( {headers.map((h, idx) => (
<th <th
key={h} key={idx}
className="px-3 py-2 text-left border-b border-gray-200 font-semibold text-gray-700 whitespace-nowrap" className="px-3 py-2 text-left border-b border-gray-200 font-semibold text-gray-700 whitespace-nowrap"
> >
{h} {h}
@ -101,109 +198,178 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row, i) => ( {rows.map((row, i) => {
<tr const isExpanded = expandedRows.has(row.signature);
key={row.signature} const months = salesPeriodMonths(
style={{ row.supply_units_in_radius,
background: i % 2 === 0 ? "#fff" : "#fafbfc", row.velocity_per_month,
borderBottom: "1px solid #f3f4f6", );
}} const hasCompetitors = row.competitor_obj_ids.length > 0;
>
<td return (
style={{ <Fragment key={row.signature}>
padding: "7px 12px", <tr
fontWeight: 700, onClick={() => {
color: "#1d4ed8", if (hasCompetitors) toggleRow(row.signature);
fontVariantNumeric: "tabular-nums", }}
}} onKeyDown={(e) => {
> if (
{row.rank} hasCompetitors &&
</td> (e.key === "Enter" || e.key === " ")
<td ) {
style={{ e.preventDefault();
padding: "7px 12px", toggleRow(row.signature);
fontWeight: 500, }
color: "#111827", }}
}} tabIndex={hasCompetitors ? 0 : undefined}
> role={hasCompetitors ? "button" : undefined}
{ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket} aria-expanded={hasCompetitors ? isExpanded : undefined}
</td> style={{
<td style={{ padding: "7px 12px", color: "#374151" }}> background: i % 2 === 0 ? "#fff" : "#fafbfc",
{row.area_bin} м² borderBottom: isExpanded ? "none" : "1px solid #f3f4f6",
</td> cursor: hasCompetitors ? "pointer" : "default",
<td }}
style={{ title={
padding: "7px 12px", hasCompetitors
color: "#374151", ? "Нажмите, чтобы увидеть ЖК-источники"
fontVariantNumeric: "tabular-nums", : undefined
}} }
> >
{row.velocity_per_month.toFixed(2)} <td
</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
style={{ style={{
display: "inline-flex", padding: "7px 12px",
alignItems: "center", fontWeight: 700,
gap: 4, color: "#1d4ed8",
fontVariantNumeric: "tabular-nums",
}} }}
> >
{`${(row.sold_pct_of_supply ?? 0).toFixed(0)}%`} {row.rank}
{row.is_oversold && ( </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 <span
style={{ style={{
background: "var(--warn-soft)", display: "inline-flex",
color: "var(--warn)", alignItems: "center",
fontSize: 10, gap: 4,
fontWeight: 600,
padding: "1px 5px",
borderRadius: 4,
whiteSpace: "nowrap",
}} }}
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>
)} )}
</span> </td>
) : ( </tr>
"—" {isExpanded && hasCompetitors && (
<CompetitorDrillIn
ids={row.competitor_obj_ids}
colSpan={TOTAL_COLS}
/>
)} )}
</td> </Fragment>
</tr> );
))} })}
</tbody> </tbody>
</table> </table>
</div> </div>