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).
|
||||
*
|
||||
* 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() {
|
||||
return useMutation<MassingRecomputeOutput, Error, MassingProgram>({
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
* и иметь один безопасный канал кодирования кадастрового номера.
|
||||
*
|
||||
* Существующие потребители паттерна (до централизации): NspdZoningBlock,
|
||||
* NspdOpportunityBlock, ParcelDrawer, AnalysisSidebar.
|
||||
* NspdOpportunityBlock, ParcelDrawer.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
"use client";
|
||||
|
||||
// Overlay 06 "АНАЛИТИКА ДОМА" — markup port from the МЕРА Оценка pixel design.
|
||||
// Accepts one composite `data` prop (default = the design fixture); page.tsx
|
||||
// feeds mapAnalytics(...) output. The price-history dots navigate to the
|
||||
// "Аналогичные объявления" overlay via onNavigate?.(2).
|
||||
// Accepts one composite `data` prop; page.tsx feeds mapAnalytics(...) output.
|
||||
// The price-history dots navigate to the "Аналогичные объявления" overlay via
|
||||
// onNavigate?.(2).
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { tokens } from "./tokens";
|
||||
import { analytics as ANALYTICS_FIXTURE } from "./fixtures";
|
||||
import { phYearX as histYearX, PH_GRID_Y } from "./mappers";
|
||||
import type { Analytics, SellTimeTier } from "./types";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -130,7 +132,7 @@ function sellTimeTierValid(tier: SellTimeTier): boolean {
|
|||
}
|
||||
|
||||
export default function AnalyticsView({
|
||||
data = ANALYTICS_FIXTURE,
|
||||
data,
|
||||
onNavigate,
|
||||
}: AnalyticsViewProps) {
|
||||
const [hoverHist, setHoverHist] = useState(false);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
// OVERLAY 07: ПРЕДЫДУЩИЕ ОЦЕНКИ (cache view).
|
||||
// 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 { cacheKpi, cacheRows } from "./fixtures";
|
||||
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
|
||||
* mapCache (CacheRow.time, produced by fmtCacheTime), one of:
|
||||
|
|
@ -34,13 +31,16 @@ function normTime(raw: string | null | undefined): string {
|
|||
}
|
||||
|
||||
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
|
||||
* historical estimate. Optional: unwired/storybook usage leaves rows inert. */
|
||||
* historical estimate. Optional: leaves rows inert when absent. */
|
||||
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 !== "—");
|
||||
// #2420 — ПАРАМЕТРЫ column inserted right after АДРЕС.
|
||||
const gridCols = showSrc
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
// Report footer for the /trade-in/v2 "МЕРА Оценка" design port.
|
||||
// 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
|
||||
// МЕРА 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 { report, version } from "./fixtures";
|
||||
import { version } from "./fixtures";
|
||||
import type { Report } from "./types";
|
||||
|
||||
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
|
||||
// (mirrors HeroBar). Optional → unwired/fixture usage keeps showing it.
|
||||
hasEstimate?: boolean;
|
||||
}
|
||||
|
||||
export function Footer({ data = report, hasEstimate }: FooterProps) {
|
||||
export function Footer({ data, hasEstimate }: FooterProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -13,12 +13,8 @@ import {
|
|||
import { safeUrl } from "@/lib/safeUrl";
|
||||
|
||||
import { tokens } from "./tokens";
|
||||
import { object, report } from "./fixtures";
|
||||
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
|
||||
// in the PDF request path so a tampered id cannot be injected.
|
||||
const PDF_UUID_RE =
|
||||
|
|
@ -154,7 +150,10 @@ function HeroMiniMap({ lat, lon }: HeroMiniMapProps) {
|
|||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
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;
|
||||
// M4: when false (no real, sufficient estimate yet) the estimate-dependent
|
||||
// meta/controls — the «ДЕЙСТВИТЕЛЕН ДО» validity line and «КАК РАССЧИТАНО» —
|
||||
|
|
@ -198,7 +197,7 @@ const pdfFilledStyle: CSSProperties = {
|
|||
};
|
||||
|
||||
export default function HeroBar({
|
||||
data = HERO_FIXTURE,
|
||||
data,
|
||||
estimateId,
|
||||
hasEstimate,
|
||||
onOpenInfo,
|
||||
|
|
|
|||
|
|
@ -2,27 +2,24 @@
|
|||
// Faithful markup port of the design's showHistory block (МЕРА Оценка.dc.html,
|
||||
// lines 457-489): "История продаж в этом доме" card (house sales) +
|
||||
// "ДКП-сделки на улице" card (3 KPI + dealine table + footer count).
|
||||
// Data arrives via the composite `data` prop (mapHistory); the design fixtures
|
||||
// are the default for unwired/storybook usage. No fetch / no local state.
|
||||
// Data arrives via the composite `data` prop (mapHistory). No fetch / no local
|
||||
// state.
|
||||
|
||||
import { tokens } from "./tokens";
|
||||
import { history, dkpRows } from "./fixtures";
|
||||
import { pluralRu } from "./mappers";
|
||||
import type { HistoryData } from "./mappers";
|
||||
|
||||
// Shared grid templates (kept verbatim from the design column tracks).
|
||||
const houseGrid = "2fr 1.4fr 1fr 1fr";
|
||||
|
||||
// Default presentation data (unwired usage): the existing design fixtures.
|
||||
const HISTORY_FIXTURE: HistoryData = { ...history, dkpRows };
|
||||
|
||||
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({
|
||||
data = HISTORY_FIXTURE,
|
||||
}: HistoryViewProps) {
|
||||
export default function HistoryView({ data }: HistoryViewProps) {
|
||||
const showAsk = data.dkpRows.some((r) => r.ask && r.ask !== "—");
|
||||
const dkpGridCols = showAsk
|
||||
? "2.2fr 1fr 1.3fr 1fr .9fr 2fr"
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
// 03 СВОДКА ОБЪЕКТА — right-rail summary card for the /trade-in/v2 "МЕРА Оценка"
|
||||
// design port. Faithful markup port of МЕРА Оценка.dc.html lines 397-422:
|
||||
// address + "В РАСЧЁТЕ" badge, four clickable per-section totals, and a
|
||||
// "КАЧЕСТВО ДАННЫХ" block with a donut. Data from fixtures; clicks fire
|
||||
// onNavigate(row.nav) only — no API, no fetching.
|
||||
// "КАЧЕСТВО ДАННЫХ" block with a donut. Data arrives via the `data` prop
|
||||
// (mapObject/mapSummary output); clicks fire onNavigate(row.nav) only — no
|
||||
// API, no fetching in this component itself.
|
||||
|
||||
"use client";
|
||||
|
||||
import { tokens } from "./tokens";
|
||||
import { object, summary } from "./fixtures";
|
||||
import type { ObjectSummaryData } from "./mappers";
|
||||
|
||||
// Default presentation data (unwired usage): the existing design fixtures.
|
||||
const OBJECT_SUMMARY_FIXTURE: ObjectSummaryData = { object, summary };
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ function rowHint(label: string): string {
|
|||
}
|
||||
|
||||
export function ObjectSummary({
|
||||
data = OBJECT_SUMMARY_FIXTURE,
|
||||
data,
|
||||
onNavigate,
|
||||
}: ObjectSummaryProps) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -3,13 +3,6 @@
|
|||
import { Fragment } from "react";
|
||||
import type { Ref } from "react";
|
||||
import { tokens } from "./tokens";
|
||||
import {
|
||||
ranges,
|
||||
resultCards,
|
||||
resultMeta,
|
||||
scatterMini,
|
||||
sources,
|
||||
} from "./fixtures";
|
||||
import type { ResultPanelData } from "./mappers";
|
||||
|
||||
const {
|
||||
|
|
@ -39,17 +32,11 @@ const {
|
|||
font,
|
||||
} = tokens;
|
||||
|
||||
// Default presentation data (unwired usage): the existing design fixtures.
|
||||
const RESULT_FIXTURE: ResultPanelData = {
|
||||
cards: resultCards,
|
||||
meta: resultMeta,
|
||||
ranges,
|
||||
scatterMini,
|
||||
sources,
|
||||
};
|
||||
|
||||
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;
|
||||
// #2264 C7: after a successful estimate the page moves focus here (a labelled,
|
||||
// programmatically-focusable region) so keyboard/SR users land on the result
|
||||
|
|
@ -92,7 +79,7 @@ function sourceName(name: string) {
|
|||
}
|
||||
|
||||
export default function ResultPanel({
|
||||
data = RESULT_FIXTURE,
|
||||
data,
|
||||
onNavigate,
|
||||
regionRef,
|
||||
}: ResultPanelProps) {
|
||||
|
|
|
|||
|
|
@ -23,12 +23,13 @@ interface SectionOverlayProps {
|
|||
onClose: () => void;
|
||||
onNavigate: (i: number) => void;
|
||||
// Mapped overlay data, fed by page.tsx (mapHistory / mapSources / mapAnalytics
|
||||
// / mapCache). Optional so unwired/storybook usage falls back to each view's
|
||||
// fixture default.
|
||||
history?: HistoryData;
|
||||
sources?: SourcesData;
|
||||
analytics?: Analytics;
|
||||
cache?: CacheData;
|
||||
// / mapCache) — always computed unconditionally (never undefined), and
|
||||
// required here in lockstep with the child views' own required `data` prop
|
||||
// (no more silent fallback to design fixtures on a missing value).
|
||||
history: HistoryData;
|
||||
sources: SourcesData;
|
||||
analytics: Analytics;
|
||||
cache: CacheData;
|
||||
// Raw current estimate — threaded straight to SourcesView's SourcesMap
|
||||
// (real per-lot lat/lon; sources/SourcesData above is pre-mapped display
|
||||
// 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 type { AggregatedEstimate } from "@/types/trade-in";
|
||||
import { tokens } from "./tokens";
|
||||
import { adRows, dealRows, marketAds, marketDeals } from "./fixtures";
|
||||
import type { AdRowData, DealRowData, SourcesData } from "./mappers";
|
||||
import { SourcesMap } from "./SourcesMap";
|
||||
|
||||
|
|
@ -12,9 +11,9 @@ import { SourcesMap } from "./SourcesMap";
|
|||
// Faithful markup port from "МЕРА Оценка.dc.html" (lines 492-537).
|
||||
// Card A: объявления в продаже (3 KPI + filter chips + adRows table).
|
||||
// Card B: фактические сделки (3 KPI + dealRows table).
|
||||
// Markup is data-driven via the `data` prop; the design fixtures stay as the
|
||||
// DEFAULT for unwired/storybook usage. (M11 below may hide an all-«—» column,
|
||||
// so the storybook render can differ from the design HTML by a dead column.)
|
||||
// Markup is data-driven via the required `data` prop (mapSources output).
|
||||
// (M11 below may hide an all-«—» column, so the render can differ from the
|
||||
// design HTML by a dead column.)
|
||||
//
|
||||
// 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
|
||||
|
|
@ -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 {
|
||||
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
|
||||
// display strings with no geometry, so SourcesMap needs the source object
|
||||
// directly. Optional: unwired/storybook usage (no estimate) renders the
|
||||
// tables only, same honest-empty contract as the rest of the mappers.
|
||||
// directly. Optional: no estimate yet renders the tables only, same honest-
|
||||
// empty contract as the rest of the mappers.
|
||||
estimate?: AggregatedEstimate | null;
|
||||
}
|
||||
|
||||
export default function SourcesView({
|
||||
data = SOURCES_FIXTURE,
|
||||
data,
|
||||
estimate = null,
|
||||
}: SourcesViewProps) {
|
||||
const adCols = visibleCols(AD_COLS, data.adRows);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue