chore(frontend): фикстуры макета не по умолчанию + удаление осиротевших компонентов
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m13s
CI Trade-In / frontend-checks (pull_request) Successful in 1m19s
CI / openapi-codegen-check (pull_request) Successful in 2m20s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m13s
CI Trade-In / frontend-checks (pull_request) Successful in 1m19s
CI / openapi-codegen-check (pull_request) Successful in 2m20s
Волна 0 инвентаризации техдолга. МЕРА: у восьми компонентов витрины v2 проп data имел значение по умолчанию из fixtures.ts — sample-данных дизайн-макета. При сбое передачи данных компонент отрисовал бы выдуманные числа вместо пустого состояния; на платном экране оценки это неотличимо от правды. Проп стал обязательным. Цепная правка в SectionOverlay: его опциональные поля пробрасывались в теперь-обязательные пропы. Проверено: единственный потребитель — app/v2/page.tsx, всегда передаёт data явно. Отдельной preview-страницы у v2/fixtures.ts нет. Статический UI-конфиг из того же файла (лейблы, версия, опции) не тронут — он не про данные отчёта. Птица: удалены шесть осиротевших компонентов (ScoreCard, AnalysisBreadcrumb, AnalysisSidebar, MassingEconomics, UserAvatar, PticaPlaceholderPanel) — по каждому подтверждён ноль импортов по всему репозиторию, включая динамические. Подчищены два ссылающихся комментария. Проверка: tsc и eslint чисто на обоих фронтах, next build успешен, vitest Птицы 264 теста зелёные.
This commit is contained in:
parent
a091da1aa8
commit
91b460b157
17 changed files with 66 additions and 973 deletions
|
|
@ -1,5 +0,0 @@
|
||||||
// ScoreCard is superseded by the tabbed dashboard in page.tsx.
|
|
||||||
// Logic is now split into OverviewTab, EnvironmentTab, LandTab, MarketTab.
|
|
||||||
// File kept to avoid breaking any external imports; exports an empty stub.
|
|
||||||
|
|
||||||
export {};
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { ChevronRight } from "lucide-react";
|
|
||||||
|
|
||||||
interface AnalysisBreadcrumbProps {
|
|
||||||
cadNum: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AnalysisBreadcrumb({ cadNum }: AnalysisBreadcrumbProps) {
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
aria-label="Breadcrumb"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 4,
|
|
||||||
flexWrap: "wrap",
|
|
||||||
minWidth: 0,
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* SiteFinder root */}
|
|
||||||
<Link
|
|
||||||
href="/site-finder"
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--fg-secondary)",
|
|
||||||
textDecoration: "none",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
SiteFinder
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<ChevronRight
|
|
||||||
size={14}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
style={{ color: "var(--fg-tertiary)", flexShrink: 0 }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Cad number */}
|
|
||||||
<Link
|
|
||||||
href={`/site-finder?selected=${encodeURIComponent(cadNum)}`}
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--fg-secondary)",
|
|
||||||
textDecoration: "none",
|
|
||||||
fontVariantNumeric: "tabular-nums",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
maxWidth: 260,
|
|
||||||
}}
|
|
||||||
title={cadNum}
|
|
||||||
>
|
|
||||||
{cadNum}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<ChevronRight
|
|
||||||
size={14}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
style={{ color: "var(--fg-tertiary)", flexShrink: 0 }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Current page */}
|
|
||||||
<span
|
|
||||||
aria-current="page"
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--fg-primary)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Анализ
|
|
||||||
</span>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,266 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
|
||||||
import { ExternalLink } from "lucide-react";
|
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface SubSection {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NavSection {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
sub?: SubSection[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const NAV_SECTIONS: NavSection[] = [
|
|
||||||
{ id: "section-1", label: "1. Объект" },
|
|
||||||
{ id: "section-2", label: "2. Земля и риски" },
|
|
||||||
{
|
|
||||||
id: "section-3",
|
|
||||||
label: "3. Рынок",
|
|
||||||
sub: [
|
|
||||||
{ id: "section-3-1", label: "3.1 Настройки выборки" },
|
|
||||||
{ id: "section-3-2", label: "3.2 Планировки" },
|
|
||||||
{ id: "section-3-3", label: "3.3 Остатки и скорость" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ id: "section-4", label: "4. Оценка" },
|
|
||||||
{ id: "section-5", label: "5. Атмосфера" },
|
|
||||||
{
|
|
||||||
id: "section-6",
|
|
||||||
label: "6. Прогноз",
|
|
||||||
sub: [
|
|
||||||
{ id: "section-6-1", label: "6.1 Прогноз по горизонтам" },
|
|
||||||
{ id: "section-6-2", label: "6.2 Сценарии" },
|
|
||||||
{ id: "section-6-3", label: "6.3 Уверенность" },
|
|
||||||
{ id: "section-6-4", label: "6.4 Рекомендация по продукту" },
|
|
||||||
{ id: "section-6-5", label: "6.5 Прозрачность скоринга" },
|
|
||||||
{ id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// All section IDs in scroll order (for IntersectionObserver)
|
|
||||||
const ALL_SECTION_IDS: string[] = NAV_SECTIONS.flatMap((s) =>
|
|
||||||
s.sub ? [s.id, ...s.sub.map((sub) => sub.id)] : [s.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Component ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function AnalysisSidebar() {
|
|
||||||
const [activeId, setActiveId] = useState<string>(ALL_SECTION_IDS[0]);
|
|
||||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
||||||
|
|
||||||
// Scrollspy via IntersectionObserver
|
|
||||||
useEffect(() => {
|
|
||||||
const candidates = ALL_SECTION_IDS.map((id) =>
|
|
||||||
document.getElementById(id),
|
|
||||||
).filter((el): el is HTMLElement => el !== null);
|
|
||||||
|
|
||||||
if (candidates.length === 0) return;
|
|
||||||
|
|
||||||
// Track which sections are visible; pick topmost visible one
|
|
||||||
const visible = new Set<string>();
|
|
||||||
|
|
||||||
observerRef.current = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
visible.add(entry.target.id);
|
|
||||||
} else {
|
|
||||||
visible.delete(entry.target.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pick the topmost section that is currently visible
|
|
||||||
const next = ALL_SECTION_IDS.find((id) => visible.has(id));
|
|
||||||
if (next) setActiveId(next);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
root: null,
|
|
||||||
// Trigger when section top enters top 60% of viewport
|
|
||||||
rootMargin: "-8px 0px -40% 0px",
|
|
||||||
threshold: 0,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
candidates.forEach((el) => observerRef.current!.observe(el));
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observerRef.current?.disconnect();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function handleAnchorClick(
|
|
||||||
e: React.MouseEvent<HTMLAnchorElement>,
|
|
||||||
targetId: string,
|
|
||||||
) {
|
|
||||||
e.preventDefault();
|
|
||||||
const el = document.getElementById(targetId);
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
}
|
|
||||||
setActiveId(targetId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside
|
|
||||||
style={{
|
|
||||||
width: 240,
|
|
||||||
flexShrink: 0,
|
|
||||||
background: "var(--bg-card)",
|
|
||||||
borderRight: "1px solid var(--border-card)",
|
|
||||||
padding: "16px 12px",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 4,
|
|
||||||
position: "sticky",
|
|
||||||
top: 56,
|
|
||||||
height: "calc(100vh - 56px)",
|
|
||||||
overflowY: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Section label */}
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 500,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.04em",
|
|
||||||
color: "var(--fg-tertiary)",
|
|
||||||
margin: "0 0 8px",
|
|
||||||
padding: "0 4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Навигация
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Nav items */}
|
|
||||||
{NAV_SECTIONS.map((section) => {
|
|
||||||
const isParentActive =
|
|
||||||
activeId === section.id ||
|
|
||||||
section.sub?.some((s) => s.id === activeId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={section.id}>
|
|
||||||
<a
|
|
||||||
href={`#${section.id}`}
|
|
||||||
onClick={(e) => handleAnchorClick(e, section.id)}
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
padding: "7px 10px",
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: isParentActive ? 600 : 400,
|
|
||||||
color: isParentActive ? "var(--accent)" : "var(--fg-secondary)",
|
|
||||||
background: isParentActive
|
|
||||||
? "var(--accent-soft)"
|
|
||||||
: "transparent",
|
|
||||||
textDecoration: "none",
|
|
||||||
transition: "background 100ms, color 100ms",
|
|
||||||
lineHeight: 1.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{section.label}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{/* Sub-sections */}
|
|
||||||
{section.sub && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
paddingLeft: 12,
|
|
||||||
marginTop: 2,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{section.sub.map((sub) => {
|
|
||||||
const isSubActive = activeId === sub.id;
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={sub.id}
|
|
||||||
href={`#${sub.id}`}
|
|
||||||
onClick={(e) => handleAnchorClick(e, sub.id)}
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
padding: "5px 10px",
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: isSubActive ? 600 : 400,
|
|
||||||
color: isSubActive
|
|
||||||
? "var(--accent)"
|
|
||||||
: "var(--fg-tertiary)",
|
|
||||||
background: isSubActive
|
|
||||||
? "var(--accent-soft)"
|
|
||||||
: "transparent",
|
|
||||||
textDecoration: "none",
|
|
||||||
transition: "background 100ms, color 100ms",
|
|
||||||
lineHeight: 1.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sub.label}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Sources footer */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: "auto",
|
|
||||||
paddingTop: 16,
|
|
||||||
borderTop: "1px solid var(--border-soft)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 500,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.04em",
|
|
||||||
color: "var(--fg-tertiary)",
|
|
||||||
margin: "0 0 6px",
|
|
||||||
padding: "0 4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Источники
|
|
||||||
</p>
|
|
||||||
{[
|
|
||||||
{ label: "Росреестр / ЕГРН", href: "https://rosreestr.gov.ru" },
|
|
||||||
{ label: "НСПД", href: "https://nspd.gov.ru" },
|
|
||||||
{ label: "2ГИС / OSM", href: "https://2gis.ru" },
|
|
||||||
].map(({ label, href }) => (
|
|
||||||
<a
|
|
||||||
key={label}
|
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 4,
|
|
||||||
padding: "4px 4px",
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--fg-tertiary)",
|
|
||||||
textDecoration: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ExternalLink size={10} strokeWidth={1.5} />
|
|
||||||
{label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,410 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MassingEconomics — LIVE financial KPI strip for «7. Концепция» (#1965 Stage 2b,
|
|
||||||
* epic #1953).
|
|
||||||
*
|
|
||||||
* Driven by the interactive 3D MassingScene: every time the user drags the
|
|
||||||
* этажность / секций sliders, Section7Concept maps the scene's `computeModel`
|
|
||||||
* result + the analysis context into a `MassingProgram` and hands it here via
|
|
||||||
* `program`. We POST it to `/api/v1/concepts/recompute` (debounced ~250 ms) and
|
|
||||||
* render the recomputed ТЭП + финмодель (NPV / IRR / выручка / себестоимость /
|
|
||||||
* прибыль / ROI).
|
|
||||||
*
|
|
||||||
* Robustness:
|
|
||||||
* • debounce — slider drags fire many programs; only the settled one is sent.
|
|
||||||
* • latest-wins — an in-flight request is superseded by a newer one via a
|
|
||||||
* monotonic request id; a stale response is dropped, never overwriting a
|
|
||||||
* fresher result (mutateAsync + id guard, no UI flicker from out-of-order).
|
|
||||||
* • last-good — on a failed recompute we keep the last successful values and
|
|
||||||
* show a subtle inline note rather than blanking the panel.
|
|
||||||
* • skeleton — a plain grey fade KPI grid while the FIRST recompute is in
|
|
||||||
* flight (no shimmer, per ui-conventions).
|
|
||||||
*
|
|
||||||
* Light-theme only (Section7 is light): the 3D viewport stays dark-canvas, but
|
|
||||||
* this strip uses the light KPI tokens via the shared KpiCard.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { AlertTriangle } from "lucide-react";
|
|
||||||
|
|
||||||
import { KpiCard } from "@/components/analytics/KpiCard";
|
|
||||||
import { Section } from "@/components/analytics/Section";
|
|
||||||
import {
|
|
||||||
priceSourceCaption,
|
|
||||||
useRecomputeMassing,
|
|
||||||
type FinancialModel,
|
|
||||||
type MassingProgram,
|
|
||||||
type MassingRecomputeOutput,
|
|
||||||
type Teap,
|
|
||||||
} from "@/lib/concept-api";
|
|
||||||
|
|
||||||
const DEBOUNCE_MS = 250;
|
|
||||||
|
|
||||||
// ── Formatters (ru microcopy, shared shape with ConceptVariantsResult) ─────────
|
|
||||||
|
|
||||||
const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
|
|
||||||
|
|
||||||
/** Compact ₽ for headline figures: "2.4 млрд ₽", "145 млн ₽". */
|
|
||||||
function formatMoneyCompact(rub: number): string {
|
|
||||||
const abs = Math.abs(rub);
|
|
||||||
if (abs >= 1e9) return `${(rub / 1e9).toFixed(1)} млрд ₽`;
|
|
||||||
if (abs >= 1e6) return `${(rub / 1e6).toFixed(0)} млн ₽`;
|
|
||||||
return `${nf.format(Math.round(rub))} ₽`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatInt(n: number): string {
|
|
||||||
return nf.format(Math.round(n));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPct(fraction: number): string {
|
|
||||||
return `${(fraction * 100).toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFar(far: number): string {
|
|
||||||
return far.toLocaleString("ru-RU", {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── KPI grid ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface KpiGridProps {
|
|
||||||
teap: Teap;
|
|
||||||
financial: FinancialModel;
|
|
||||||
/** Регламентная КСИТ-цель (max_far) — to flag the КСИТ over-cap. */
|
|
||||||
farTarget: number;
|
|
||||||
/** True → факт-КСИТ превышает регламентный потолок (model.over). */
|
|
||||||
ksitOver: boolean;
|
|
||||||
/** Dim the strip while a fresher recompute is in flight (last-good values). */
|
|
||||||
stale: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function KpiGrid({
|
|
||||||
teap,
|
|
||||||
financial,
|
|
||||||
farTarget,
|
|
||||||
ksitOver,
|
|
||||||
stale,
|
|
||||||
}: KpiGridProps) {
|
|
||||||
const netPositive =
|
|
||||||
financial.net_profit_rub > 0
|
|
||||||
? true
|
|
||||||
: financial.net_profit_rub < 0
|
|
||||||
? false
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: stale ? 0.55 : 1,
|
|
||||||
transition: "opacity 150ms linear",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* ТЭП */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<KpiCard
|
|
||||||
label="Общая площадь (GFA)"
|
|
||||||
value={formatInt(teap.total_floor_area_sqm)}
|
|
||||||
unit="м²"
|
|
||||||
hint="Поэтажная площадь всех корпусов = пятно застройки × этажность."
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Продаваемая площадь"
|
|
||||||
value={formatInt(teap.residential_area_sqm)}
|
|
||||||
unit="м²"
|
|
||||||
hint="Жилая к продаже = (GFA − нежилой 1-й этаж) × коэффициент эффективности класса."
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Квартир"
|
|
||||||
value={formatInt(teap.apartments_count)}
|
|
||||||
unit="шт"
|
|
||||||
hint="Продаваемая площадь ÷ средняя площадь квартиры класса."
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="КСИТ — факт / цель"
|
|
||||||
value={`${formatFar(teap.density)} / ${formatFar(farTarget)}`}
|
|
||||||
delta={{
|
|
||||||
value: ksitOver
|
|
||||||
? "Факт превышает регламентный потолок"
|
|
||||||
: "В пределах регламента",
|
|
||||||
positive: ksitOver ? false : true,
|
|
||||||
}}
|
|
||||||
hint="КСИТ (коэффициент строительного использования) = надземная GFA ÷ площадь участка. Цель — предельный max_far по регламенту НСПД."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Финмодель */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
|
||||||
gap: 12,
|
|
||||||
marginTop: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<KpiCard
|
|
||||||
label="Выручка (GDV)"
|
|
||||||
value={formatMoneyCompact(financial.revenue_rub)}
|
|
||||||
hint="Продаваемая площадь × цена продажи м² (+ машиноместа и нежилой 1-й этаж по ценам класса)."
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Себестоимость"
|
|
||||||
value={formatMoneyCompact(financial.cost_rub)}
|
|
||||||
hint="Строительство (GFA × удельная по классу) + ПИР, сети, услуги застройщика, непредвиденные, маркетинг + стоимость земли."
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Чистая прибыль"
|
|
||||||
value={formatMoneyCompact(financial.net_profit_rub)}
|
|
||||||
delta={{
|
|
||||||
value:
|
|
||||||
netPositive === true
|
|
||||||
? "Положительная (после НДС и налога на прибыль)"
|
|
||||||
: netPositive === false
|
|
||||||
? "Отрицательная (после НДС и налога на прибыль)"
|
|
||||||
: "Нулевая",
|
|
||||||
positive: netPositive,
|
|
||||||
}}
|
|
||||||
hint="Выручка − себестоимость − НДС на нежилое − налог на прибыль 25%. Жильё по ДДУ от НДС освобождено."
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="ROI на затраты"
|
|
||||||
value={formatPct(financial.roi)}
|
|
||||||
delta={{
|
|
||||||
value: `Чистая маржа на выручку ${formatPct(financial.margin_pct)}`,
|
|
||||||
positive: null,
|
|
||||||
}}
|
|
||||||
hint="Чистая прибыль ÷ себестоимость. Маржа считается от выручки."
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="NPV (DCF)"
|
|
||||||
value={formatMoneyCompact(financial.npv_rub)}
|
|
||||||
delta={{
|
|
||||||
value: `Дисконт ${formatPct(financial.discount_rate_used)} годовых`,
|
|
||||||
positive:
|
|
||||||
financial.npv_rub > 0
|
|
||||||
? true
|
|
||||||
: financial.npv_rub < 0
|
|
||||||
? false
|
|
||||||
: null,
|
|
||||||
}}
|
|
||||||
hint={`Сумма дисконтированных помесячных денежных потоков по графику стройки и продаж (ставка дисконта ${formatPct(
|
|
||||||
financial.discount_rate_used,
|
|
||||||
)} годовых).`}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="IRR (DCF, годовой)"
|
|
||||||
value={formatPct(financial.irr)}
|
|
||||||
delta={{
|
|
||||||
value: financial.irr_is_proxy
|
|
||||||
? "Оценочный (вырожденный поток, не DCF)"
|
|
||||||
: "Дисконтированный денежный поток",
|
|
||||||
positive: financial.irr_is_proxy
|
|
||||||
? null
|
|
||||||
: financial.irr > financial.discount_rate_used
|
|
||||||
? true
|
|
||||||
: false,
|
|
||||||
}}
|
|
||||||
hint="Годовая внутренняя ставка доходности тех же денежных потоков (ставка, при которой NPV = 0)."
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Цена продажи жилья"
|
|
||||||
value={`${formatInt(financial.price_per_sqm_used)} ₽/м²`}
|
|
||||||
delta={{ value: priceSourceCaption(financial), positive: null }}
|
|
||||||
hint="Цена, заложенная в выручку: медиана объявлений Objective по району (источник указан выше)."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Skeleton (grey fade, no shimmer — ui-conventions) ──────────────────────────
|
|
||||||
|
|
||||||
function SkeletonGrid() {
|
|
||||||
const cells = Array.from({ length: 7 });
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{cells.map((_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
height: 92,
|
|
||||||
background: "var(--bg-card-alt)",
|
|
||||||
border: "1px solid var(--border-card)",
|
|
||||||
borderRadius: 12,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Component ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/**
|
|
||||||
* The current massing program (Σ footprint × floors + context), mapped by
|
|
||||||
* Section7Concept from the 3D scene's computeModel result. `null` until the
|
|
||||||
* scene has fired its first onModelChange (or when geometry is missing).
|
|
||||||
*/
|
|
||||||
program: MassingProgram | null;
|
|
||||||
/** Регламентная КСИТ-цель (max_far) — for the факт/цель comparison + over-cap. */
|
|
||||||
farTarget: number;
|
|
||||||
/** True when факт-КСИТ exceeds the cap (model.over, computed scene-side). */
|
|
||||||
ksitOver: boolean;
|
|
||||||
/**
|
|
||||||
* True when the parcel is regulatorily constrained for МКД (gate-blocked /
|
|
||||||
* non-residential / ЗОУИТ-СЗЗ) — drives the honest «условный расчёт» caveat
|
|
||||||
* below the strip alongside the negative-economics case.
|
|
||||||
*/
|
|
||||||
gateConstrained?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MassingEconomics({
|
|
||||||
program,
|
|
||||||
farTarget,
|
|
||||||
ksitOver,
|
|
||||||
gateConstrained = false,
|
|
||||||
}: Props) {
|
|
||||||
const recompute = useRecomputeMassing();
|
|
||||||
|
|
||||||
// Last successful result kept locally so a failed/stale recompute never blanks
|
|
||||||
// the panel (last-good values stay on screen).
|
|
||||||
const [result, setResult] = useState<MassingRecomputeOutput | null>(null);
|
|
||||||
const [errored, setErrored] = useState(false);
|
|
||||||
|
|
||||||
// Monotonic request id → latest-wins: a response is applied only if it belongs
|
|
||||||
// to the most recently issued request, so out-of-order arrivals are dropped.
|
|
||||||
const reqIdRef = useRef(0);
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
// Stable JSON key so we only recompute when the program actually changes
|
|
||||||
// (not on every parent re-render that hands an equal-but-new object).
|
|
||||||
const programKey = program ? JSON.stringify(program) : null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!program) return;
|
|
||||||
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
debounceRef.current = setTimeout(() => {
|
|
||||||
const id = ++reqIdRef.current;
|
|
||||||
recompute
|
|
||||||
.mutateAsync(program)
|
|
||||||
.then((out) => {
|
|
||||||
if (id !== reqIdRef.current) return; // stale — a newer request won.
|
|
||||||
setResult(out);
|
|
||||||
setErrored(false);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (id !== reqIdRef.current) return; // stale failure — ignore.
|
|
||||||
setErrored(true); // keep last-good `result`.
|
|
||||||
});
|
|
||||||
}, DEBOUNCE_MS);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
};
|
|
||||||
// recompute is a stable mutation object; we key off the serialized program.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [programKey]);
|
|
||||||
|
|
||||||
// No program yet → nothing to show (parent gates this on geometry anyway).
|
|
||||||
if (!program) return null;
|
|
||||||
|
|
||||||
// First recompute in flight, no last-good value yet → skeleton.
|
|
||||||
if (!result) {
|
|
||||||
return (
|
|
||||||
<Section
|
|
||||||
title="Экономика по 3D-модели"
|
|
||||||
subtitle="Пересчёт ТЭП и финмодели по текущей массе застройки (этажность / секции) из 3D-модели слева."
|
|
||||||
>
|
|
||||||
<SkeletonGrid />
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A fresher request is in flight over the last-good values.
|
|
||||||
const stale = recompute.isPending;
|
|
||||||
|
|
||||||
// Honest «условный расчёт» caveat: when the economics turn negative OR the
|
|
||||||
// parcel is regulatorily constrained (gate-blocked / ЗОУИТ-СЗЗ / нежилое),
|
|
||||||
// we say so plainly rather than presenting the figures as a viable project.
|
|
||||||
const economicsNegative =
|
|
||||||
result.financial.net_profit_rub < 0 || result.financial.npv_rub < 0;
|
|
||||||
const showConditionalNote = economicsNegative || gateConstrained;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section
|
|
||||||
title="Экономика по 3D-модели"
|
|
||||||
subtitle="Пересчитывается вживую при изменении этажности / секций в 3D-модели слева. Цена продажи — из оценки участка, без рыночного DB-запроса; ТЭП синтезируется из массинг-программы."
|
|
||||||
>
|
|
||||||
<KpiGrid
|
|
||||||
teap={result.teap}
|
|
||||||
financial={result.financial}
|
|
||||||
farTarget={farTarget}
|
|
||||||
ksitOver={ksitOver}
|
|
||||||
stale={stale}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showConditionalNote ? (
|
|
||||||
<p
|
|
||||||
role="note"
|
|
||||||
style={{
|
|
||||||
margin: "12px 0 0",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
gap: 8,
|
|
||||||
fontSize: 12,
|
|
||||||
lineHeight: "16px",
|
|
||||||
color: "var(--warn)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertTriangle
|
|
||||||
size={16}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{ flexShrink: 0, marginTop: 1 }}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
Расчёт условный: участок ограничен регламентом (см. блокеры выше)
|
|
||||||
и/или экономика отрицательна при текущих вводных. Измените
|
|
||||||
этажность, число секций или класс — модель пересчитается.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{errored ? (
|
|
||||||
<p
|
|
||||||
role="status"
|
|
||||||
style={{
|
|
||||||
margin: "12px 0 0",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
fontSize: 12,
|
|
||||||
color: "var(--warn)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertTriangle size={16} strokeWidth={1.5} aria-hidden="true" />
|
|
||||||
Не удалось пересчитать экономику по последнему изменению — показаны
|
|
||||||
предыдущие значения. Измените параметры ещё раз для повторного
|
|
||||||
расчёта.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Building2 } from "lucide-react";
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function getStoredOrgId(): string | null {
|
|
||||||
// Guard against SSR — localStorage not available on server
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
try {
|
|
||||||
return localStorage.getItem("gd_org_id");
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function orgInitials(orgId: string): string {
|
|
||||||
// Build a 2-letter monogram from org ID string
|
|
||||||
const parts = orgId
|
|
||||||
.toUpperCase()
|
|
||||||
.replace(/[^A-ZА-Я0-9]/gu, " ")
|
|
||||||
.split(" ")
|
|
||||||
.filter(Boolean);
|
|
||||||
if (parts.length === 0) return "??";
|
|
||||||
if (parts.length === 1) return parts[0].slice(0, 2);
|
|
||||||
return parts[0][0] + parts[1][0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Component ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function UserAvatar() {
|
|
||||||
const [orgId, setOrgId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Hydration-safe: read localStorage after mount
|
|
||||||
useEffect(() => {
|
|
||||||
setOrgId(getStoredOrgId());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const displayLabel = orgId ?? "Demo Org";
|
|
||||||
const initials = orgId ? orgInitials(orgId) : "DO";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
title={displayLabel}
|
|
||||||
>
|
|
||||||
{/* Avatar circle */}
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
style={{
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: "var(--accent-soft)",
|
|
||||||
border: "1px solid var(--border-card)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{orgId ? (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--accent)",
|
|
||||||
letterSpacing: "0.02em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{initials}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<Building2
|
|
||||||
size={14}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
style={{ color: "var(--accent)" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Org name — hidden on narrow viewports via maxWidth trick */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--fg-secondary)",
|
|
||||||
maxWidth: 140,
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{displayLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PticaPlaceholderPanel — honest "В разработке" panel for cockpit tabs/sections
|
|
||||||
* that aren't wired in INCREMENT 1 (Scenarios / Reports / Compare).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
label: string;
|
|
||||||
hint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PticaPlaceholderPanel({ label, hint }: Props) {
|
|
||||||
return (
|
|
||||||
<div className={`${styles.panel} ${styles.placeholderPanel}`}>
|
|
||||||
<div className={styles.soon}>{label}</div>
|
|
||||||
{hint && <p>{hint}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -330,7 +330,7 @@ export interface MassingRecomputeOutput {
|
||||||
* rule; uses the shared `apiFetch` (base URL + session header + Content-Type).
|
* rule; uses the shared `apiFetch` (base URL + session header + Content-Type).
|
||||||
*
|
*
|
||||||
* Stage 2b drives this off the 3D MassingScene's `onModelChange` (debounced),
|
* Stage 2b drives this off the 3D MassingScene's `onModelChange` (debounced),
|
||||||
* with latest-wins sequencing handled by the caller (see MassingEconomics).
|
* with latest-wins sequencing handled by the caller.
|
||||||
*/
|
*/
|
||||||
export function useRecomputeMassing() {
|
export function useRecomputeMassing() {
|
||||||
return useMutation<MassingRecomputeOutput, Error, MassingProgram>({
|
return useMutation<MassingRecomputeOutput, Error, MassingProgram>({
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
* и иметь один безопасный канал кодирования кадастрового номера.
|
* и иметь один безопасный канал кодирования кадастрового номера.
|
||||||
*
|
*
|
||||||
* Существующие потребители паттерна (до централизации): NspdZoningBlock,
|
* Существующие потребители паттерна (до централизации): NspdZoningBlock,
|
||||||
* NspdOpportunityBlock, ParcelDrawer, AnalysisSidebar.
|
* NspdOpportunityBlock, ParcelDrawer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// Overlay 06 "АНАЛИТИКА ДОМА" — markup port from the МЕРА Оценка pixel design.
|
// Overlay 06 "АНАЛИТИКА ДОМА" — markup port from the МЕРА Оценка pixel design.
|
||||||
// Accepts one composite `data` prop (default = the design fixture); page.tsx
|
// Accepts one composite `data` prop; page.tsx feeds mapAnalytics(...) output.
|
||||||
// feeds mapAnalytics(...) output. The price-history dots navigate to the
|
// The price-history dots navigate to the "Аналогичные объявления" overlay via
|
||||||
// "Аналогичные объявления" overlay via onNavigate?.(2).
|
// onNavigate?.(2).
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { analytics as ANALYTICS_FIXTURE } from "./fixtures";
|
|
||||||
import { phYearX as histYearX, PH_GRID_Y } from "./mappers";
|
import { phYearX as histYearX, PH_GRID_Y } from "./mappers";
|
||||||
import type { Analytics, SellTimeTier } from "./types";
|
import type { Analytics, SellTimeTier } from "./types";
|
||||||
|
|
||||||
interface AnalyticsViewProps {
|
interface AnalyticsViewProps {
|
||||||
data?: Analytics;
|
// Required on the app path (v2/page.tsx -> SectionOverlay always supplies
|
||||||
|
// mapAnalytics output) — an omitted prop must be a TS error, not a silent
|
||||||
|
// fallback to fabricated house-analytics numbers.
|
||||||
|
data: Analytics;
|
||||||
onNavigate?: (i: number) => void;
|
onNavigate?: (i: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,7 +132,7 @@ function sellTimeTierValid(tier: SellTimeTier): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AnalyticsView({
|
export default function AnalyticsView({
|
||||||
data = ANALYTICS_FIXTURE,
|
data,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: AnalyticsViewProps) {
|
}: AnalyticsViewProps) {
|
||||||
const [hoverHist, setHoverHist] = useState(false);
|
const [hoverHist, setHoverHist] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
// OVERLAY 07: ПРЕДЫДУЩИЕ ОЦЕНКИ (cache view).
|
// OVERLAY 07: ПРЕДЫДУЩИЕ ОЦЕНКИ (cache view).
|
||||||
// Faithful markup port of МЕРА Оценка.dc.html lines 593-613.
|
// Faithful markup port of МЕРА Оценка.dc.html lines 593-613.
|
||||||
// Data via `data` prop (mapCache output); defaults to fixtures (cacheKpi + cacheRows).
|
// Data via the required `data` prop (mapCache output).
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { cacheKpi, cacheRows } from "./fixtures";
|
|
||||||
import type { CacheData } from "./mappers";
|
import type { CacheData } from "./mappers";
|
||||||
|
|
||||||
const FIXTURE_CACHE: CacheData = { kpis: cacheKpi, rows: cacheRows };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M8 — ВРЕМЯ column normalizer. `r.time` arrives PRE-FORMATTED as a string from
|
* M8 — ВРЕМЯ column normalizer. `r.time` arrives PRE-FORMATTED as a string from
|
||||||
* mapCache (CacheRow.time, produced by fmtCacheTime), one of:
|
* mapCache (CacheRow.time, produced by fmtCacheTime), one of:
|
||||||
|
|
@ -34,13 +31,16 @@ function normTime(raw: string | null | undefined): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CacheViewProps {
|
interface CacheViewProps {
|
||||||
data?: CacheData;
|
// Required on the app path (v2/page.tsx -> SectionOverlay always supplies
|
||||||
|
// mapCache output) — an omitted prop must be a TS error, not a silent
|
||||||
|
// fallback to fabricated «Предыдущие оценки» rows.
|
||||||
|
data: CacheData;
|
||||||
/** Row-click handler (#2420) — navigates to the full report for that
|
/** Row-click handler (#2420) — navigates to the full report for that
|
||||||
* historical estimate. Optional: unwired/storybook usage leaves rows inert. */
|
* historical estimate. Optional: leaves rows inert when absent. */
|
||||||
onSelectRow?: (id: string) => void;
|
onSelectRow?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CacheView({ data = FIXTURE_CACHE, onSelectRow }: CacheViewProps) {
|
export function CacheView({ data, onSelectRow }: CacheViewProps) {
|
||||||
const showSrc = data.rows.some((r) => r.src && r.src !== "—");
|
const showSrc = data.rows.some((r) => r.src && r.src !== "—");
|
||||||
// #2420 — ПАРАМЕТРЫ column inserted right after АДРЕС.
|
// #2420 — ПАРАМЕТРЫ column inserted right after АДРЕС.
|
||||||
const gridCols = showSrc
|
const gridCols = showSrc
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,23 @@
|
||||||
// Report footer for the /trade-in/v2 "МЕРА Оценка" design port.
|
// Report footer for the /trade-in/v2 "МЕРА Оценка" design port.
|
||||||
// Faithful markup port of the design footer (МЕРА Оценка.dc.html, lines 426-439):
|
// Faithful markup port of the design footer (МЕРА Оценка.dc.html, lines 426-439):
|
||||||
// report id / date / valid-until on the left, a decorative centre line, and the
|
// report id / date / valid-until on the left, a decorative centre line, and the
|
||||||
// МЕРА v2.0.6 wordmark on the right. Static markup, data from fixtures.
|
// МЕРА v2.0.6 wordmark on the right. Static markup, id/date/validUntil via `data`.
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { report, version } from "./fixtures";
|
import { version } from "./fixtures";
|
||||||
import type { Report } from "./types";
|
import type { Report } from "./types";
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
data?: Report;
|
// Required on the app path (v2/page.tsx always supplies mapReport output or
|
||||||
|
// EMPTY_REPORT, never the design fixtures) — an omitted prop must be a TS
|
||||||
|
// error, not a silent fallback to a fabricated report id/date.
|
||||||
|
data: Report;
|
||||||
// M4: hide the «ДЕЙСТВИТЕЛЕН ДО» validity line when there is no real estimate
|
// M4: hide the «ДЕЙСТВИТЕЛЕН ДО» validity line when there is no real estimate
|
||||||
// (mirrors HeroBar). Optional → unwired/fixture usage keeps showing it.
|
// (mirrors HeroBar). Optional → unwired/fixture usage keeps showing it.
|
||||||
hasEstimate?: boolean;
|
hasEstimate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Footer({ data = report, hasEstimate }: FooterProps) {
|
export function Footer({ data, hasEstimate }: FooterProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,8 @@ import {
|
||||||
import { safeUrl } from "@/lib/safeUrl";
|
import { safeUrl } from "@/lib/safeUrl";
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { object, report } from "./fixtures";
|
|
||||||
import type { HeroBarData } from "./mappers";
|
import type { HeroBarData } from "./mappers";
|
||||||
|
|
||||||
// Default presentation data (unwired usage): the existing design fixtures.
|
|
||||||
const HERO_FIXTURE: HeroBarData = { report, object };
|
|
||||||
|
|
||||||
// estimate ids are server-issued UUIDs — reject anything else before it lands
|
// estimate ids are server-issued UUIDs — reject anything else before it lands
|
||||||
// in the PDF request path so a tampered id cannot be injected.
|
// in the PDF request path so a tampered id cannot be injected.
|
||||||
const PDF_UUID_RE =
|
const PDF_UUID_RE =
|
||||||
|
|
@ -154,7 +150,10 @@ function HeroMiniMap({ lat, lon }: HeroMiniMapProps) {
|
||||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
interface HeroBarProps {
|
interface HeroBarProps {
|
||||||
data?: HeroBarData;
|
// Required on the app path (v2/page.tsx always supplies mapObject/mapReport
|
||||||
|
// output, never the design fixtures) — an omitted prop must be a TS error,
|
||||||
|
// not a silent fallback to fabricated report numbers.
|
||||||
|
data: HeroBarData;
|
||||||
estimateId?: string | null;
|
estimateId?: string | null;
|
||||||
// M4: when false (no real, sufficient estimate yet) the estimate-dependent
|
// M4: when false (no real, sufficient estimate yet) the estimate-dependent
|
||||||
// meta/controls — the «ДЕЙСТВИТЕЛЕН ДО» validity line and «КАК РАССЧИТАНО» —
|
// meta/controls — the «ДЕЙСТВИТЕЛЕН ДО» validity line and «КАК РАССЧИТАНО» —
|
||||||
|
|
@ -198,7 +197,7 @@ const pdfFilledStyle: CSSProperties = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function HeroBar({
|
export default function HeroBar({
|
||||||
data = HERO_FIXTURE,
|
data,
|
||||||
estimateId,
|
estimateId,
|
||||||
hasEstimate,
|
hasEstimate,
|
||||||
onOpenInfo,
|
onOpenInfo,
|
||||||
|
|
|
||||||
|
|
@ -2,27 +2,24 @@
|
||||||
// Faithful markup port of the design's showHistory block (МЕРА Оценка.dc.html,
|
// Faithful markup port of the design's showHistory block (МЕРА Оценка.dc.html,
|
||||||
// lines 457-489): "История продаж в этом доме" card (house sales) +
|
// lines 457-489): "История продаж в этом доме" card (house sales) +
|
||||||
// "ДКП-сделки на улице" card (3 KPI + dealine table + footer count).
|
// "ДКП-сделки на улице" card (3 KPI + dealine table + footer count).
|
||||||
// Data arrives via the composite `data` prop (mapHistory); the design fixtures
|
// Data arrives via the composite `data` prop (mapHistory). No fetch / no local
|
||||||
// are the default for unwired/storybook usage. No fetch / no local state.
|
// state.
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { history, dkpRows } from "./fixtures";
|
|
||||||
import { pluralRu } from "./mappers";
|
import { pluralRu } from "./mappers";
|
||||||
import type { HistoryData } from "./mappers";
|
import type { HistoryData } from "./mappers";
|
||||||
|
|
||||||
// Shared grid templates (kept verbatim from the design column tracks).
|
// Shared grid templates (kept verbatim from the design column tracks).
|
||||||
const houseGrid = "2fr 1.4fr 1fr 1fr";
|
const houseGrid = "2fr 1.4fr 1fr 1fr";
|
||||||
|
|
||||||
// Default presentation data (unwired usage): the existing design fixtures.
|
|
||||||
const HISTORY_FIXTURE: HistoryData = { ...history, dkpRows };
|
|
||||||
|
|
||||||
interface HistoryViewProps {
|
interface HistoryViewProps {
|
||||||
data?: HistoryData;
|
// Required on the app path (v2/page.tsx -> SectionOverlay always supplies
|
||||||
|
// mapHistory output) — an omitted prop must be a TS error, not a silent
|
||||||
|
// fallback to fabricated house-sale/deal rows.
|
||||||
|
data: HistoryData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HistoryView({
|
export default function HistoryView({ data }: HistoryViewProps) {
|
||||||
data = HISTORY_FIXTURE,
|
|
||||||
}: HistoryViewProps) {
|
|
||||||
const showAsk = data.dkpRows.some((r) => r.ask && r.ask !== "—");
|
const showAsk = data.dkpRows.some((r) => r.ask && r.ask !== "—");
|
||||||
const dkpGridCols = showAsk
|
const dkpGridCols = showAsk
|
||||||
? "2.2fr 1fr 1.3fr 1fr .9fr 2fr"
|
? "2.2fr 1fr 1.3fr 1fr .9fr 2fr"
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
// 03 СВОДКА ОБЪЕКТА — right-rail summary card for the /trade-in/v2 "МЕРА Оценка"
|
// 03 СВОДКА ОБЪЕКТА — right-rail summary card for the /trade-in/v2 "МЕРА Оценка"
|
||||||
// design port. Faithful markup port of МЕРА Оценка.dc.html lines 397-422:
|
// design port. Faithful markup port of МЕРА Оценка.dc.html lines 397-422:
|
||||||
// address + "В РАСЧЁТЕ" badge, four clickable per-section totals, and a
|
// address + "В РАСЧЁТЕ" badge, four clickable per-section totals, and a
|
||||||
// "КАЧЕСТВО ДАННЫХ" block with a donut. Data from fixtures; clicks fire
|
// "КАЧЕСТВО ДАННЫХ" block with a donut. Data arrives via the `data` prop
|
||||||
// onNavigate(row.nav) only — no API, no fetching.
|
// (mapObject/mapSummary output); clicks fire onNavigate(row.nav) only — no
|
||||||
|
// API, no fetching in this component itself.
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { object, summary } from "./fixtures";
|
|
||||||
import type { ObjectSummaryData } from "./mappers";
|
import type { ObjectSummaryData } from "./mappers";
|
||||||
|
|
||||||
// Default presentation data (unwired usage): the existing design fixtures.
|
|
||||||
const OBJECT_SUMMARY_FIXTURE: ObjectSummaryData = { object, summary };
|
|
||||||
|
|
||||||
interface ObjectSummaryProps {
|
interface ObjectSummaryProps {
|
||||||
data?: ObjectSummaryData;
|
// Required on the app path (v2/page.tsx always supplies mapObject/mapSummary
|
||||||
|
// output) — an omitted prop must be a TS error, not a silent fallback to
|
||||||
|
// fabricated per-section totals.
|
||||||
|
data: ObjectSummaryData;
|
||||||
onNavigate: (i: number) => void;
|
onNavigate: (i: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,7 +38,7 @@ function rowHint(label: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ObjectSummary({
|
export function ObjectSummary({
|
||||||
data = OBJECT_SUMMARY_FIXTURE,
|
data,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: ObjectSummaryProps) {
|
}: ObjectSummaryProps) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,6 @@
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import type { Ref } from "react";
|
import type { Ref } from "react";
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import {
|
|
||||||
ranges,
|
|
||||||
resultCards,
|
|
||||||
resultMeta,
|
|
||||||
scatterMini,
|
|
||||||
sources,
|
|
||||||
} from "./fixtures";
|
|
||||||
import type { ResultPanelData } from "./mappers";
|
import type { ResultPanelData } from "./mappers";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -39,17 +32,11 @@ const {
|
||||||
font,
|
font,
|
||||||
} = tokens;
|
} = tokens;
|
||||||
|
|
||||||
// Default presentation data (unwired usage): the existing design fixtures.
|
|
||||||
const RESULT_FIXTURE: ResultPanelData = {
|
|
||||||
cards: resultCards,
|
|
||||||
meta: resultMeta,
|
|
||||||
ranges,
|
|
||||||
scatterMini,
|
|
||||||
sources,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ResultPanelProps {
|
interface ResultPanelProps {
|
||||||
data?: ResultPanelData;
|
// Required on the app path (v2/page.tsx always supplies mapResultPanel
|
||||||
|
// output) — an omitted prop must be a TS error, not a silent fallback to
|
||||||
|
// fabricated price/analytics numbers on a paid estimate screen.
|
||||||
|
data: ResultPanelData;
|
||||||
onNavigate: (i: number) => void;
|
onNavigate: (i: number) => void;
|
||||||
// #2264 C7: after a successful estimate the page moves focus here (a labelled,
|
// #2264 C7: after a successful estimate the page moves focus here (a labelled,
|
||||||
// programmatically-focusable region) so keyboard/SR users land on the result
|
// programmatically-focusable region) so keyboard/SR users land on the result
|
||||||
|
|
@ -92,7 +79,7 @@ function sourceName(name: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ResultPanel({
|
export default function ResultPanel({
|
||||||
data = RESULT_FIXTURE,
|
data,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
regionRef,
|
regionRef,
|
||||||
}: ResultPanelProps) {
|
}: ResultPanelProps) {
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,13 @@ interface SectionOverlayProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onNavigate: (i: number) => void;
|
onNavigate: (i: number) => void;
|
||||||
// Mapped overlay data, fed by page.tsx (mapHistory / mapSources / mapAnalytics
|
// Mapped overlay data, fed by page.tsx (mapHistory / mapSources / mapAnalytics
|
||||||
// / mapCache). Optional so unwired/storybook usage falls back to each view's
|
// / mapCache) — always computed unconditionally (never undefined), and
|
||||||
// fixture default.
|
// required here in lockstep with the child views' own required `data` prop
|
||||||
history?: HistoryData;
|
// (no more silent fallback to design fixtures on a missing value).
|
||||||
sources?: SourcesData;
|
history: HistoryData;
|
||||||
analytics?: Analytics;
|
sources: SourcesData;
|
||||||
cache?: CacheData;
|
analytics: Analytics;
|
||||||
|
cache: CacheData;
|
||||||
// Raw current estimate — threaded straight to SourcesView's SourcesMap
|
// Raw current estimate — threaded straight to SourcesView's SourcesMap
|
||||||
// (real per-lot lat/lon; sources/SourcesData above is pre-mapped display
|
// (real per-lot lat/lon; sources/SourcesData above is pre-mapped display
|
||||||
// strings with no geometry). Optional/null: no estimate yet -> map degrades
|
// strings with no geometry). Optional/null: no estimate yet -> map degrades
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import type { CSSProperties, ReactNode } from "react";
|
||||||
import { safeUrl } from "@/lib/safeUrl";
|
import { safeUrl } from "@/lib/safeUrl";
|
||||||
import type { AggregatedEstimate } from "@/types/trade-in";
|
import type { AggregatedEstimate } from "@/types/trade-in";
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { adRows, dealRows, marketAds, marketDeals } from "./fixtures";
|
|
||||||
import type { AdRowData, DealRowData, SourcesData } from "./mappers";
|
import type { AdRowData, DealRowData, SourcesData } from "./mappers";
|
||||||
import { SourcesMap } from "./SourcesMap";
|
import { SourcesMap } from "./SourcesMap";
|
||||||
|
|
||||||
|
|
@ -12,9 +11,9 @@ import { SourcesMap } from "./SourcesMap";
|
||||||
// Faithful markup port from "МЕРА Оценка.dc.html" (lines 492-537).
|
// Faithful markup port from "МЕРА Оценка.dc.html" (lines 492-537).
|
||||||
// Card A: объявления в продаже (3 KPI + filter chips + adRows table).
|
// Card A: объявления в продаже (3 KPI + filter chips + adRows table).
|
||||||
// Card B: фактические сделки (3 KPI + dealRows table).
|
// Card B: фактические сделки (3 KPI + dealRows table).
|
||||||
// Markup is data-driven via the `data` prop; the design fixtures stay as the
|
// Markup is data-driven via the required `data` prop (mapSources output).
|
||||||
// DEFAULT for unwired/storybook usage. (M11 below may hide an all-«—» column,
|
// (M11 below may hide an all-«—» column, so the render can differ from the
|
||||||
// so the storybook render can differ from the design HTML by a dead column.)
|
// design HTML by a dead column.)
|
||||||
//
|
//
|
||||||
// M7 — the two "tables" are CSS grids of <span>s. They now carry real table
|
// M7 — the two "tables" are CSS grids of <span>s. They now carry real table
|
||||||
// semantics via ARIA roles (table / row / columnheader / cell) layered onto the
|
// semantics via ARIA roles (table / row / columnheader / cell) layered onto the
|
||||||
|
|
@ -485,26 +484,20 @@ function DataTable<R>({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default presentation data (unwired/storybook usage): the existing design
|
|
||||||
// fixtures. AdRow[]/DealRow[] satisfy AdRowData[]/DealRowData[] (extras optional).
|
|
||||||
const SOURCES_FIXTURE: SourcesData = {
|
|
||||||
adRows,
|
|
||||||
dealRows,
|
|
||||||
marketAds,
|
|
||||||
marketDeals,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SourcesViewProps {
|
interface SourcesViewProps {
|
||||||
data?: SourcesData;
|
// Required on the app path (v2/page.tsx -> SectionOverlay always supplies
|
||||||
|
// mapSources output) — an omitted prop must be a TS error, not a silent
|
||||||
|
// fallback to fabricated listing/deal rows.
|
||||||
|
data: SourcesData;
|
||||||
// Raw estimate (real lat/lon per lot) — the table rows above are pre-mapped
|
// Raw estimate (real lat/lon per lot) — the table rows above are pre-mapped
|
||||||
// display strings with no geometry, so SourcesMap needs the source object
|
// display strings with no geometry, so SourcesMap needs the source object
|
||||||
// directly. Optional: unwired/storybook usage (no estimate) renders the
|
// directly. Optional: no estimate yet renders the tables only, same honest-
|
||||||
// tables only, same honest-empty contract as the rest of the mappers.
|
// empty contract as the rest of the mappers.
|
||||||
estimate?: AggregatedEstimate | null;
|
estimate?: AggregatedEstimate | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SourcesView({
|
export default function SourcesView({
|
||||||
data = SOURCES_FIXTURE,
|
data,
|
||||||
estimate = null,
|
estimate = null,
|
||||||
}: SourcesViewProps) {
|
}: SourcesViewProps) {
|
||||||
const adCols = visibleCols(AD_COLS, data.adRows);
|
const adCols = visibleCols(AD_COLS, data.adRows);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue