chore(frontend): фикстуры макета не по умолчанию + удаление осиротевших компонентов #2747

Merged
lekss361 merged 1 commit from chore/frontend-dead-code-and-fixtures into main 2026-08-06 18:59:51 +00:00
17 changed files with 66 additions and 973 deletions

View file

@ -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 {};

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>({

View file

@ -7,7 +7,7 @@
* и иметь один безопасный канал кодирования кадастрового номера.
*
* Существующие потребители паттерна (до централизации): NspdZoningBlock,
* NspdOpportunityBlock, ParcelDrawer, AnalysisSidebar.
* NspdOpportunityBlock, ParcelDrawer.
*/
/**

View file

@ -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);

View file

@ -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

View file

@ -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={{

View file

@ -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,

View file

@ -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"

View file

@ -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 (

View file

@ -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) {

View file

@ -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

View file

@ -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);