fix(sf-11): Top layouts — sales period column + drill-in expand
Add «Срок продажи (мес)» column (supply / velocity, colored by urgency) and click-to-expand drill-in row showing competitor_obj_ids as DOM.РФ linked badges. Both fields were already present in TopLayoutRow type. Closes #271 item 11
This commit is contained in:
parent
e1300b1955
commit
d3a51c3657
1 changed files with 251 additions and 95 deletions
|
|
@ -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://наш.дом.рф/сервисы/каталог-новостроек/объект/${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,168 @@ 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={{
|
<>
|
||||||
padding: "7px 12px",
|
<tr
|
||||||
fontWeight: 700,
|
key={row.signature}
|
||||||
color: "#1d4ed8",
|
onClick={() => {
|
||||||
fontVariantNumeric: "tabular-nums",
|
if (hasCompetitors) toggleRow(row.signature);
|
||||||
}}
|
}}
|
||||||
>
|
style={{
|
||||||
{row.rank}
|
background: i % 2 === 0 ? "#fff" : "#fafbfc",
|
||||||
</td>
|
borderBottom: isExpanded ? "none" : "1px solid #f3f4f6",
|
||||||
<td
|
cursor: hasCompetitors ? "pointer" : "default",
|
||||||
style={{
|
}}
|
||||||
padding: "7px 12px",
|
title={
|
||||||
fontWeight: 500,
|
hasCompetitors
|
||||||
color: "#111827",
|
? "Нажмите, чтобы увидеть ЖК-источники"
|
||||||
}}
|
: undefined
|
||||||
>
|
}
|
||||||
{ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket}
|
>
|
||||||
</td>
|
<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
|
|
||||||
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
|
||||||
|
key={`${row.signature}-drill`}
|
||||||
|
ids={row.competitor_obj_ids}
|
||||||
|
colSpan={TOTAL_COLS}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</td>
|
</>
|
||||||
</tr>
|
);
|
||||||
))}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue