feat(site-finder): per-room bucket breakdown в VelocityBlock (#166)

VelocityBucketStat + optional by_room_bucket fields.
'По комнатности' aggregate table + per-competitor inline mini-row.
Backward compat via optional field presence guards.

Closes #161 (frontend part)

Co-authored-by: lekss361 <claudestars@proton.me>
This commit is contained in:
lekss361 2026-05-15 10:12:42 +03:00 committed by GitHub
parent d7fbaa0528
commit faa99abb94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 128 additions and 0 deletions

View file

@ -3,6 +3,17 @@ import type { Velocity } from "@/types/site-finder";
import { SectionLabel } from "@/components/ui/SectionLabel";
import { EmptyState } from "@/components/ui/EmptyState";
const BUCKET_ORDER = ["студия", "1", "2", "3", "4+"] as const;
type KnownBucket = (typeof BUCKET_ORDER)[number];
const BUCKET_LABEL: Record<KnownBucket, string> = {
студия: "Студия",
"1": "1-к",
"2": "2-к",
"3": "3-к",
"4+": "4+",
};
interface VelocityBlockProps {
velocity: Velocity | null | undefined;
}
@ -121,6 +132,102 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
({velocity.months_observed} мес)
</div>
{/* By room bucket aggregate */}
{velocity.by_room_bucket && (
<div style={{ marginBottom: 12 }}>
<SectionLabel>По комнатности</SectionLabel>
<table
style={{
width: "100%",
fontSize: 12,
borderCollapse: "collapse",
marginTop: 4,
}}
>
<thead>
<tr
style={{ color: "#6b7280", borderBottom: "1px solid #e5e7eb" }}
>
<th
style={{
textAlign: "left",
padding: "4px 8px",
fontWeight: 500,
}}
>
Комната
</th>
<th
style={{
textAlign: "right",
padding: "4px 8px",
fontWeight: 500,
}}
>
Сделок
</th>
<th
style={{
textAlign: "right",
padding: "4px 8px",
fontWeight: 500,
}}
>
м²
</th>
<th
style={{
textAlign: "right",
padding: "4px 8px",
fontWeight: 500,
}}
>
ЖК
</th>
</tr>
</thead>
<tbody>
{BUCKET_ORDER.map((b) => {
const stat = velocity.by_room_bucket?.[b];
if (!stat) return null;
return (
<tr key={b} style={{ borderBottom: "1px solid #f3f4f6" }}>
<td style={{ padding: "4px 8px" }}>{BUCKET_LABEL[b]}</td>
<td
style={{
padding: "4px 8px",
textAlign: "right",
fontWeight: 500,
}}
>
{stat.units.toLocaleString("ru")}
</td>
<td
style={{
padding: "4px 8px",
textAlign: "right",
color: "#6b7280",
}}
>
{Math.round(stat.sqm).toLocaleString("ru")}
</td>
<td
style={{
padding: "4px 8px",
textAlign: "right",
color: "#6b7280",
}}
>
{stat.complexes_count}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Top competitors */}
{velocity.sample_competitors.length > 0 && (
<div>
@ -188,6 +295,19 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
{c.obj_class}
</span>
)}
{c.by_room_bucket && (
<div
style={{ fontSize: 10, color: "#9ca3af", marginTop: 2 }}
>
{BUCKET_ORDER.filter(
(b) => (c.by_room_bucket?.[b] ?? 0) > 0,
)
.map(
(b) => `${BUCKET_LABEL[b]} ${c.by_room_bucket![b]}`,
)
.join(" · ")}
</div>
)}
</td>
<td
style={{

View file

@ -205,6 +205,12 @@ export interface Pipeline24mo {
}
// D2 (#34) — velocity-score: темп продаж конкурентов
export interface VelocityBucketStat {
units: number;
sqm: number;
complexes_count: number;
}
export interface VelocityCompetitor {
obj_id: number;
name: string | null;
@ -213,6 +219,7 @@ export interface VelocityCompetitor {
total_sqm_period: number;
dev_name?: string | null;
district_name?: string | null;
by_room_bucket?: Record<string, number>;
}
export interface VelocityPeriod {
@ -229,6 +236,7 @@ export interface Velocity {
months_observed: number;
period: VelocityPeriod;
sample_competitors: VelocityCompetitor[];
by_room_bucket?: Record<string, VelocityBucketStat>;
}
// G5 (#32) — Gate verdict: can_build_mkd