gendesign/frontend/src/components/analytics/ObjectSpecsTable.tsx
lekss361 cac06b2ff9 feat(22k): object page full exposure — 9 blocks (sales/quartirography/metro/photos/docs/specs/checks)
Adds backend queries + 4 API endpoints + 5 frontend components to expose all
Wave A+B data on the per-object drill-in page (/analytics/objects/[id]).

Backend (analytics_queries.py + analytics.py):
- object_full_detail(): extended SELECT with all 30 new cols from 113_22begh
  (building specs, yard, OVZ, catalog UI, metro, scores, dev_group_name)
- object_flats_quartirography(): GROUP BY rooms from domrf_kn_flats
- object_obj_checks(): SELECT from domrf_obj_checks (22f)
- object_documents(): SELECT from domrf_kn_documents (22i)
- 4 new endpoints: /object/{id}/full, /quartirography, /checks, /documents

Frontend:
- ObjectSpecsTable.tsx — 30+ spec rows, conditional (nulls hidden)
- ObjectQuartirographyTable.tsx — rooms x count/free/area/price
- ObjectChecksBadges.tsx — 6 badge pills (passed/failed)
- ObjectDocumentsList.tsx — PDF links grouped by doc_type with safeUrl()
- ObjectMetroList.tsx — top3 metro entries with line color dot
- page.tsx: 9 blocks incl. DOM.RF scores KPI row, checks, documents, specs

Closes #297 (Phase 6 — 22k frontend exposure)
2026-05-17 18:50:41 +03:00

204 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { ObjectFullDetail } from "@/types/analytics";
interface SpecRow {
label: string;
value: string | null;
}
function yn(v: boolean | null | undefined): string | null {
if (v === null || v === undefined) return null;
return v ? "Да" : "Нет";
}
function num(v: number | null | undefined, suffix = ""): string | null {
if (v === null || v === undefined) return null;
return `${v.toLocaleString("ru-RU")}${suffix}`;
}
function numFmt(
v: number | null | undefined,
decimals = 0,
suffix = "",
): string | null {
if (v === null || v === undefined) return null;
return (
v.toLocaleString("ru-RU", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}) + suffix
);
}
function price(v: number | null | undefined): string | null {
if (v === null || v === undefined) return null;
return (
(v / 1_000_000).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}) + " млн ₽"
);
}
function priceM2(v: number | null | undefined): string | null {
if (v === null || v === undefined) return null;
return v.toLocaleString("ru-RU", { maximumFractionDigits: 0 }) + " ₽/м²";
}
export function ObjectSpecsTable({ d }: { d: ObjectFullDetail }) {
const specs: SpecRow[] = [
// Identity
{ label: "Класс объекта", value: d.obj_class },
{ label: "Класс энергоэффективности", value: d.energy_eff },
{ label: "Материал стен", value: d.wall_type },
{ label: "Первый этаж", value: d.first_floor_type },
// Building
{ label: "Подъездов", value: num(d.section_count) },
{ label: "Этажей (max)", value: num(d.floor_max) },
{ label: "Высота потолков", value: numFmt(d.ceiling_height_m, 2, " м") },
{
label: "Лифты пассажирские",
value:
d.elevators_passenger_count === 0
? "Нет"
: num(d.elevators_passenger_count),
},
{
label: "Лифты грузовые",
value:
d.elevators_cargo_count === 0 ? "Нет" : num(d.elevators_cargo_count),
},
// Parking
{
label: "Машино-мест (парковка)",
value: num(d.parking_total_slots),
},
{
label: "Гостевые (приказ.)",
value: num(d.guest_parking_inside_count),
},
{
label: "Гостевые (улица)",
value: num(d.guest_parking_outside_count),
},
{
label: "Обеспеченность парковкой",
value: numFmt(d.parking_provision_pct, 1, "%"),
},
// Apartments
{ label: "Квартир всего", value: num(d.flat_count) },
{
label: "Площадь квартир",
value:
d.flat_area_min != null && d.flat_area_max != null
? `${numFmt(d.flat_area_min, 1)} ${numFmt(d.flat_area_max, 1)} м²`
: null,
},
{
label: "Средняя площадь квартиры",
value: numFmt(d.avg_flat_area_m2, 1, " м²"),
},
{
label: "Вариантов отделки",
value: num(d.finishing_variants_count),
},
{ label: "Свободная планировка", value: yn(d.has_free_planning) },
// Price
{
label: "Цена (от)",
value: price(d.price_min_rub),
},
{
label: "Цена (до)",
value: price(d.price_max_rub),
},
{
label: "Цена/м² (от)",
value: priceM2(d.price_per_m2_min),
},
{
label: "Цена/м² (до)",
value: priceM2(d.price_per_m2_max),
},
// Yard
{
label: "Детских площадок",
value: num(d.playground_kids_count),
},
{
label: "Спортивных площадок",
value: num(d.playground_sport_count),
},
{ label: "Велодорожки", value: yn(d.has_bike_paths) },
{
label: "Мусорные площадки",
value: num(d.trash_areas_count),
},
// OVZ
{ label: "Пандус", value: yn(d.has_ramp) },
{ label: "Понижающие платформы (ОВЗ)", value: yn(d.has_low_platforms) },
{
label: "Инвалидный подъёмник",
value: yn(d.has_wheelchair_lift),
},
// Developer
{ label: "Девелопер", value: d.dev_name },
{ label: "Группа компаний", value: d.dev_group_name },
{ label: "Эскроу", value: yn(d.escrow) },
{
label: "Декларация №",
value: d.project_declaration_num,
},
{
label: "Дата публикации проекта",
value: d.project_published_at?.slice(0, 10) ?? null,
},
];
const filtered = specs.filter((s) => s.value !== null);
if (filtered.length === 0) {
return (
<p style={{ color: "#9ca3af", fontSize: 13 }}>
Характеристики ещё не загружены.
</p>
);
}
return (
<div style={{ overflowX: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 13,
}}
>
<tbody>
{filtered.map((s) => (
<tr key={s.label} style={{ borderTop: "1px solid #f3f4f6" }}>
<td
style={{
padding: "7px 12px 7px 0",
color: "#5b6066",
whiteSpace: "nowrap",
verticalAlign: "top",
width: "50%",
}}
>
{s.label}
</td>
<td
style={{
padding: "7px 0",
fontWeight: 500,
fontVariantNumeric: "tabular-nums",
}}
>
{s.value}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}