fix(sf-11): Top layouts — sales period column + drill-in expand #285
3 changed files with 264 additions and 98 deletions
2
frontend/package-lock.json
generated
2
frontend/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%"
|
||||
>
|
||||
>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%"
|
||||
>
|
||||
>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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue