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",
|
"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": {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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%"
|
|
||||||
>
|
>
|
||||||
>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>
|
||||||
)}
|
)}
|
||||||
</span>
|
</td>
|
||||||
) : (
|
</tr>
|
||||||
"—"
|
{isExpanded && hasCompetitors && (
|
||||||
|
<CompetitorDrillIn
|
||||||
|
ids={row.competitor_obj_ids}
|
||||||
|
colSpan={TOTAL_COLS}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</td>
|
</Fragment>
|
||||||
</tr>
|
);
|
||||||
))}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue