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[] }) {
|
||||
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,168 @@ 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 (
|
||||
<>
|
||||
<tr
|
||||
key={row.signature}
|
||||
onClick={() => {
|
||||
if (hasCompetitors) toggleRow(row.signature);
|
||||
}}
|
||||
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
|
||||
key={`${row.signature}-drill`}
|
||||
ids={row.competitor_obj_ids}
|
||||
colSpan={TOTAL_COLS}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue