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:
lekss361 2026-05-17 16:07:30 +03:00
parent e1300b1955
commit d3a51c3657

View file

@ -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,13 +198,31 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row, i) => ( {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 <tr
key={row.signature} key={row.signature}
onClick={() => {
if (hasCompetitors) toggleRow(row.signature);
}}
style={{ style={{
background: i % 2 === 0 ? "#fff" : "#fafbfc", background: i % 2 === 0 ? "#fff" : "#fafbfc",
borderBottom: "1px solid #f3f4f6", borderBottom: isExpanded ? "none" : "1px solid #f3f4f6",
cursor: hasCompetitors ? "pointer" : "default",
}} }}
title={
hasCompetitors
? "Нажмите, чтобы увидеть ЖК-источники"
: undefined
}
> >
<td <td
style={{ style={{
@ -164,6 +279,14 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
) )
: "—"} : "—"}
</td> </td>
<td
style={{
padding: "7px 12px",
textAlign: "right",
}}
>
<SalesPeriodCell months={months} />
</td>
<td <td
style={{ style={{
padding: "7px 12px", padding: "7px 12px",
@ -202,8 +325,41 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
"—" "—"
)} )}
</td> </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>
)}
</td>
</tr> </tr>
))} {isExpanded && hasCompetitors && (
<CompetitorDrillIn
key={`${row.signature}-drill`}
ids={row.competitor_obj_ids}
colSpan={TOTAL_COLS}
/>
)}
</>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>