135 lines
4.4 KiB
TypeScript
135 lines
4.4 KiB
TypeScript
"use client";
|
||
|
||
import type { RecommendBucket } from "@/types/analytics";
|
||
|
||
interface DerivedBucket extends RecommendBucket {
|
||
effective_share_pct: number;
|
||
effective_units: number | null;
|
||
effective_revenue_rub: number | null;
|
||
}
|
||
|
||
interface Props {
|
||
rows: DerivedBucket[];
|
||
hasAllocation: boolean;
|
||
priceFactor?: number;
|
||
elasticity?: number;
|
||
}
|
||
|
||
const fmtInt = (n: number | null | undefined) =>
|
||
n == null ? "—" : Math.round(n).toLocaleString("ru");
|
||
const fmtMln = (rub: number | null | undefined) =>
|
||
rub == null ? "—" : `${(rub / 1_000_000).toFixed(1)} млн ₽`;
|
||
|
||
export function RecommendBucketsTable({
|
||
rows,
|
||
hasAllocation,
|
||
priceFactor = 1,
|
||
elasticity = -1.5,
|
||
}: Props) {
|
||
const pfPow = priceFactor > 0 ? priceFactor ** elasticity : 1;
|
||
return (
|
||
<div style={{ overflowX: "auto" }}>
|
||
<table
|
||
style={{
|
||
width: "100%",
|
||
borderCollapse: "collapse",
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
<thead>
|
||
<tr style={{ background: "#f6f7f9" }}>
|
||
{[
|
||
"Бакет",
|
||
"Успех",
|
||
"Доля",
|
||
"Сделок",
|
||
"Площадь ср., м²",
|
||
"Цена p25, ₽/м²",
|
||
"Цена медиана, ₽/м²",
|
||
"Цена p75, ₽/м²",
|
||
"Темп, кв/мес",
|
||
...(hasAllocation ? ["Юнитов", "Срок, мес", "Выручка"] : []),
|
||
].map((h) => (
|
||
<th
|
||
key={h}
|
||
style={{
|
||
padding: "8px 10px",
|
||
textAlign: "left",
|
||
fontWeight: 600,
|
||
borderBottom: "1px solid #e6e8ec",
|
||
}}
|
||
>
|
||
{h}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((r, i) => {
|
||
const adjVelocity = (r.velocity_per_month ?? 0) * pfPow;
|
||
const months =
|
||
r.effective_units && adjVelocity > 0
|
||
? r.effective_units / adjVelocity
|
||
: null;
|
||
const adjPriceMedian = r.price_median_per_m2 * priceFactor;
|
||
const adjP25 = r.price_p25_per_m2 * priceFactor;
|
||
const adjP75 = r.price_p75_per_m2 * priceFactor;
|
||
const adjRevenue =
|
||
r.effective_revenue_rub != null
|
||
? r.effective_revenue_rub * priceFactor
|
||
: null;
|
||
return (
|
||
<tr
|
||
key={r.bucket}
|
||
style={{
|
||
borderBottom: "1px solid #eef0f3",
|
||
background: i % 2 ? "#fafbfc" : "#fff",
|
||
}}
|
||
>
|
||
<td style={td}>
|
||
<strong>{r.bucket}</strong>
|
||
</td>
|
||
<td style={{ ...td, textAlign: "center" }}>
|
||
{r.is_top_success === true ? (
|
||
<span
|
||
title="Топ-bucket в районе за 24м (быстрые продажи + высокая цена + компактная площадь)"
|
||
style={{ cursor: "help", fontSize: 16 }}
|
||
>
|
||
⭐
|
||
</span>
|
||
) : null}
|
||
</td>
|
||
<td style={td}>{r.effective_share_pct.toFixed(1)}%</td>
|
||
<td style={td}>{fmtInt(r.deal_count)}</td>
|
||
<td style={td}>{r.area_avg_m2.toFixed(1)}</td>
|
||
<td style={td}>{fmtInt(adjP25)}</td>
|
||
<td style={{ ...td, fontWeight: 600 }}>
|
||
{fmtInt(adjPriceMedian)}
|
||
</td>
|
||
<td style={td}>{fmtInt(adjP75)}</td>
|
||
<td style={td}>
|
||
{adjVelocity > 0 ? adjVelocity.toFixed(1) : "—"}
|
||
</td>
|
||
{hasAllocation ? (
|
||
<>
|
||
<td style={td}>{fmtInt(r.effective_units)}</td>
|
||
<td style={td}>
|
||
{months == null
|
||
? "—"
|
||
: months > 60
|
||
? "60+"
|
||
: months.toFixed(1)}
|
||
</td>
|
||
<td style={td}>{fmtMln(adjRevenue)}</td>
|
||
</>
|
||
) : null}
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const td: React.CSSProperties = { padding: "8px 10px" };
|