feat(ptica): complete cockpit lower-grid + bottom-grid sections
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m6s
CI / openapi-codegen-check (pull_request) Successful in 2m1s

Port the prototype's missing sections — the React cockpit had only hero +
Development Scan, so the deployed page was incomplete («поплывшее»). Add:
- lower-grid: ОКС · Development Potential · Recommended Product (квартирография)
  · Инсоляционная матрица (opens 3D);
- bottom-grid: Investment Clearance · Buy Signal · Legal Status · Site Verdict
  («Приобретать/Нельзя/Нужна проверка» plaque + checklist).
Move ОКС + Инсоляция out of DevelopmentScan → scan = 7 cards (prototype IA).

Data honesty: Development Potential (real КСИТ/площадь/плотность), Recommended
Product (forecast product_tz), Buy Signal + Site Verdict (forecast deficit +
gate_verdict, real, three-state), Legal (zouit/red_lines/gate). Investment
Clearance all «—» «после финмодели (§23)» — no fabricated numbers. Typed
{value,isReal,caption} adapters; scoped dark CSS; forecast-pending safe.
Cleanup: drop orphaned adaptOksCard/adaptInsolationCard; add .metric/.bignum.
This commit is contained in:
Light1YT 2026-06-21 00:33:15 +05:00
parent 4205ecb483
commit 24b5e6047f
15 changed files with 1441 additions and 54 deletions

View file

@ -33,6 +33,8 @@ import { PticaShell } from "@/components/site-finder/ptica/PticaShell";
import type { PticaTab } from "@/components/site-finder/ptica/PticaShell";
import { PticaHero } from "@/components/site-finder/ptica/PticaHero";
import { DevelopmentScan } from "@/components/site-finder/ptica/DevelopmentScan";
import { PticaLowerGrid } from "@/components/site-finder/ptica/cockpit/PticaLowerGrid";
import { PticaBottomGrid } from "@/components/site-finder/ptica/cockpit/PticaBottomGrid";
import { PticaScenarios } from "@/components/site-finder/ptica/scenarios/PticaScenarios";
import { PticaReports } from "@/components/site-finder/ptica/reports/PticaReports";
import { PticaCompare } from "@/components/site-finder/ptica/compare/PticaCompare";
@ -146,6 +148,16 @@ export function PticaPageContent({ cad }: Props) {
analysis={analysis}
onOpenDrawer={handleOpenDrawer}
/>
<PticaLowerGrid
analysis={analysis}
forecastReport={forecastReport}
onOpenDrawer={handleOpenDrawer}
/>
<PticaBottomGrid
analysis={analysis}
forecastReport={forecastReport}
onOpenDrawer={handleOpenDrawer}
/>
</>
)}
{tab === "scenarios" && (

View file

@ -656,7 +656,7 @@
}
.scanGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(7, 1fr);
gap: 10px;
}
.scanCard {
@ -1879,7 +1879,11 @@
}
@media (max-width: 1200px) {
.scanGrid {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(3, 1fr);
}
.lowerGrid,
.bottomGrid {
grid-template-columns: 1fr 1fr;
}
.forecastGrid,
.reportGrid {
@ -1894,7 +1898,9 @@
display: none;
}
.hero,
.scanGrid {
.scanGrid,
.lowerGrid,
.bottomGrid {
grid-template-columns: 1fr;
}
.scorePanel {
@ -2167,3 +2173,309 @@
border-radius: 999px;
backdrop-filter: blur(6px);
}
/* ===================== LOWER GRID (cockpit) =====================
Ported from prototype .lower-grid: ОКС · Development Potential ·
Recommended Product · Инсоляционная матрица. */
.lowerGrid {
display: grid;
grid-template-columns: 1.05fr 1.05fr 1.15fr 0.95fr;
gap: 10px;
}
/* shared detail-btn that needs top separation inside flow cards */
.detailBtnSpaced {
margin-top: 10px;
align-self: flex-start;
}
/* ОКС — isometric building visual + empty-state */
.isoBox {
height: 84px;
display: grid;
place-items: center;
margin: 2px 0 10px;
}
.isoBox svg {
width: 100%;
height: 100%;
}
.isoFaceLeft {
fill: rgba(210, 101, 91, 0.22);
stroke: var(--accent-red);
stroke-width: 1;
}
.isoFaceTop {
fill: rgba(210, 101, 91, 0.13);
stroke: var(--accent-red);
stroke-width: 1;
}
.isoFaceRight {
fill: rgba(210, 101, 91, 0.3);
stroke: var(--accent-red);
stroke-width: 1;
}
/* Development Potential — metric tiles */
.potentialGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 9px;
margin-bottom: 10px;
}
/* grid-item wrapper (label + value stack); min-width:0 stops long values
blowing out the grid track. */
.metric {
min-width: 0;
}
.metricLabel {
font-size: 8.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-soft);
margin-bottom: 2px;
}
.metricValue {
font-size: 17px;
font-weight: 600;
color: var(--text-strong);
}
.metricValueLg {
font-size: 22px;
}
.metricValuePlaceholder {
color: var(--text-soft);
font-weight: 500;
}
.metricUnit {
font-size: 10px;
color: var(--text-muted);
margin-left: 3px;
}
/* Recommended Product — mix-bar head (квартирография / доля) */
.mixHead {
display: flex;
justify-content: space-between;
font-size: 8.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-soft);
margin-bottom: 8px;
}
/* Инсоляционная матрица — faint preview massing + ПРЕВЬЮ placeholder */
.isoBoxPreview {
height: 74px;
display: grid;
place-items: center;
margin: 2px 0 10px;
opacity: 0.55;
}
.isoBoxPreview svg {
width: 100%;
height: 100%;
}
.isoPreviewLeft {
fill: rgba(150, 192, 214, 0.1);
stroke: var(--border-strong);
stroke-width: 1;
}
.isoPreviewTop {
fill: rgba(150, 192, 214, 0.06);
stroke: var(--border-strong);
stroke-width: 1;
}
.isoPreviewRight {
fill: rgba(150, 192, 214, 0.14);
stroke: var(--border-strong);
stroke-width: 1;
}
.isoPreviewEdge {
stroke: var(--border);
stroke-width: 0.6;
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 8px;
}
.placeholderSoon {
font-size: 22px;
letter-spacing: 0.12em;
color: var(--text-soft);
font-weight: 600;
}
.placeholder p {
font-size: 10px;
color: var(--text-soft);
margin: 0;
max-width: 180px;
}
/* ===================== BOTTOM GRID (cockpit) =====================
Ported from prototype .bottom-grid: Investment Clearance · Buy Signal ·
Legal Status · Site Verdict. */
.bottomGrid {
display: grid;
grid-template-columns: 1.5fr 0.62fr 0.86fr 1.2fr;
gap: 10px;
}
/* Investment Clearance — bignum row */
.bignumRow {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.bignum {
min-width: 0;
}
.bignumLabel {
font-size: 8.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-soft);
margin-bottom: 4px;
}
.bignumValue {
font-size: 30px;
font-weight: 500;
letter-spacing: 0.01em;
color: var(--text-strong);
line-height: 1;
}
.bignumValuePlaceholder {
color: var(--text-soft);
}
.bignumUnit {
display: block;
font-size: 9px;
color: var(--text-muted);
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.bignumCaption {
margin: 12px 0 0;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-soft);
}
/* Buy Signal — centered gauge */
.buySignalCard {
display: grid;
place-items: center;
}
.buySignalTitle {
align-self: flex-start;
}
/* Legal Status — Нет/значение grid */
.legalGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--line);
background: var(--border-faint);
border: var(--line) solid var(--border-faint);
border-radius: var(--radius-sm);
overflow: hidden;
}
.legalCell {
background: var(--surface-2);
padding: 9px 11px;
}
.legalKey {
display: block;
font-size: 8.5px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-soft);
margin-bottom: 3px;
}
.legalValue {
font-size: 12.5px;
font-weight: 600;
color: var(--text);
}
.legalValuePlaceholder {
color: var(--text-soft);
font-weight: 500;
}
/* Site Verdict — headline plaque + checklist */
.verdictCard {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 10px;
text-align: center;
}
.verdictSplit {
display: grid;
grid-template-columns: auto 1fr;
gap: 18px;
align-items: center;
}
.verdictLead {
text-align: center;
}
.verdictLeadLabel {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-soft);
margin-bottom: 6px;
}
.verdictWord {
font-size: 30px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
text-shadow: 0 0 22px var(--glow);
}
.verdictWordGood {
color: var(--accent-green);
}
.verdictWordWarn {
color: var(--accent-yellow);
}
.verdictWordBad {
color: var(--accent-red);
}
.checklist {
display: grid;
gap: 8px;
text-align: left;
}
.checkItem {
display: flex;
align-items: flex-start;
gap: 9px;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-muted);
}
.checkIc {
width: 16px;
height: 16px;
flex: none;
}
.checkItemOk .checkIc {
color: var(--accent-green);
border: var(--line-strong) solid currentColor;
border-radius: 50%;
padding: 2px;
}
.checkItemWarn .checkIc {
color: var(--accent-yellow);
}
.checkItemBad .checkIc {
color: var(--accent-red);
}

View file

@ -41,9 +41,11 @@ export function BuildabilityGauge({ gauge, title }: Props) {
return (
<div className={styles.gaugePanel}>
{title && (
<div className={`${styles.cardTitle} ${styles.cardTitleGauge}`}>
{title}
</div>
)}
<div className={styles.gauge}>
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle

View file

@ -1,9 +1,11 @@
"use client";
/**
* DevelopmentScan "Development Scan" section: a responsive grid of ScanCards
* DevelopmentScan "Development Scan" section: a 7-card grid of ScanCards
* (Градостроительство / Ограничения / Инженерия / Рынок / Экономика / Риски /
* Среда + ОКС). Every card's real-vs-placeholder content comes from the adapter.
* Среда), matching the prototype scan-grid. ОКС and Инсоляция live in the
* lower-grid (PticaLowerGrid). Every card's real-vs-placeholder content comes
* from the adapter.
*
* Each card maps to its drawer key and opens the matching detail drawer via the
* `onOpenDrawer` callback threaded down from PticaPageContent.
@ -20,8 +22,6 @@ import {
adaptMarketCard,
adaptEconomyCard,
adaptEnvironmentCard,
adaptOksCard,
adaptInsolationCard,
adaptRiskGauge,
} from "@/components/site-finder/ptica/ptica-adapt";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
@ -89,14 +89,8 @@ export function DevelopmentScan({ analysis, onOpenDrawer }: Props) {
drawerKey="environment"
onOpen={onOpenDrawer}
/>
<ScanCard card={adaptOksCard()} drawerKey="oks" onOpen={onOpenDrawer} />
{/* Инсоляция · 3D-масса opens the future3d/insolation drawer (3D
massing + shadow preview) so the 3D module is discoverable here. */}
<ScanCard
card={adaptInsolationCard(analysis)}
drawerKey="insolation"
onOpen={onOpenDrawer}
/>
{/* ОКС and Инсоляция · 3D-масса live in the lower-grid (PticaLowerGrid)
per the prototype, keeping the scan at 7 cards. */}
</section>
</>
);

View file

@ -0,0 +1,30 @@
"use client";
/**
* BuySignalCard bottom-grid «Buy Signal» gauge. Derived from the §22 forecast
* exec-summary deficit_index (same logic as the invest-score buy-signal): >0.05
* приобретать / <0.05 пропустить / else наблюдать. Advisory (§22). While the
* forecast is pending the gauge shows the honest «после прогноза» placeholder.
*/
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import type { ForecastReport } from "@/types/forecast";
import { BuildabilityGauge } from "@/components/site-finder/ptica/BuildabilityGauge";
import { adaptBuySignalGauge } from "@/components/site-finder/ptica/ptica-adapt";
interface Props {
forecastReport?: ForecastReport;
}
export function BuySignalCard({ forecastReport }: Props) {
const { gauge } = adaptBuySignalGauge(forecastReport);
return (
<div className={`${styles.card} ${styles.buySignalCard}`}>
<h3 className={`${styles.cardTitle} ${styles.buySignalTitle}`}>
Buy Signal
</h3>
<BuildabilityGauge gauge={gauge} title="" />
</div>
);
}

View file

@ -0,0 +1,57 @@
"use client";
/**
* DevelopmentPotentialCard lower-grid «Development Potential» card. Renders the
* REAL ёмкость-метрики (площадь · КСИТ · плотность · высота · пятно · продаваемая)
* from `adaptPotentialCard` (geometry + НСПД-регламент, post-#1847) as the
* prototype's `.potential-grid` tiles, honest «» where an input is absent.
* "Подробнее" opens the potential drawer.
*/
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import type { ParcelAnalysis } from "@/types/site-finder";
import { adaptPotentialCard } from "@/components/site-finder/ptica/ptica-adapt";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
interface Props {
analysis: ParcelAnalysis;
onOpenDrawer: (key: DrawerKey) => void;
}
export function DevelopmentPotentialCard({ analysis, onOpenDrawer }: Props) {
const { metrics } = adaptPotentialCard(analysis);
return (
<div className={styles.card}>
<div className={styles.cardHead}>
<h3 className={styles.cardTitle}>Development Potential</h3>
</div>
<div className={styles.potentialGrid}>
{metrics.map((m) => (
<div key={m.label} className={styles.metric}>
<div className={styles.metricLabel}>{m.label}</div>
<div
className={`${styles.metricValue} ${m.lead ? styles.metricValueLg : ""} ${
m.isReal ? "" : styles.metricValuePlaceholder
}`}
title={m.caption}
>
{m.value}
{m.unit && <span className={styles.metricUnit}>{m.unit}</span>}
</div>
</div>
))}
</div>
<button
type="button"
className={styles.detailBtn}
onClick={() => onOpenDrawer("potential")}
title="Открыть детализацию потенциала"
>
Подробнее
</button>
</div>
);
}

View file

@ -0,0 +1,56 @@
"use client";
/**
* InsolationCard lower-grid «Инсоляционная матрица» preview card. Ports the
* prototype's faint isometric massing visual + «ПРЕВЬЮ» state; «Открыть 3D»
* opens the insolation / future3d drawer (the 3D massing + shadow module).
*/
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
interface Props {
onOpenDrawer: (key: DrawerKey) => void;
}
export function InsolationCard({ onOpenDrawer }: Props) {
return (
<div className={styles.card}>
<div className={styles.cardHead}>
<h3 className={styles.cardTitle}>Инсоляционная матрица</h3>
<span className={styles.openTag}>превью</span>
</div>
<div className={styles.isoBoxPreview}>
<svg viewBox="0 0 200 74" fill="none" aria-hidden="true">
<path
d="M60 50 L100 30 L100 8 L60 28 Z"
className={styles.isoPreviewLeft}
/>
<path
d="M100 30 L140 50 L140 28 L100 8 Z"
className={styles.isoPreviewTop}
/>
<path
d="M60 50 L100 70 L140 50 L100 30 Z"
className={styles.isoPreviewRight}
/>
<path d="M100 30 L100 70" className={styles.isoPreviewEdge} />
</svg>
</div>
<div className={styles.placeholder}>
<div className={styles.placeholderSoon}>ПРЕВЬЮ</div>
<p>Тени по времени суток в 3D-модуле массы застройки.</p>
<button
type="button"
className={styles.detailBtn}
onClick={() => onOpenDrawer("insolation")}
title="Открыть 3D-модуль"
>
Открыть 3D
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,59 @@
"use client";
/**
* InvestmentClearance bottom-grid «Investment Clearance» card. The §23 finance
* model is NOT in the backend, so every bignum (GDV / Cost / Profit / ROI / IRR)
* is an honest «» placeholder with a «после финмодели (§23)» caption NO
* fabricated numbers. The bignum layout matches the prototype. "Подробнее" opens
* the finance drawer.
*/
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import { adaptInvestmentClearance } from "@/components/site-finder/ptica/ptica-adapt";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
interface Props {
onOpenDrawer: (key: DrawerKey) => void;
}
export function InvestmentClearance({ onOpenDrawer }: Props) {
const { bignums, caption } = adaptInvestmentClearance();
return (
<div className={styles.card}>
<div className={styles.cardHead}>
<h3 className={styles.cardTitle}>
Investment Clearance
<span className={styles.cardTitleSub}>финансовая модель</span>
</h3>
<button
type="button"
className={styles.detailBtn}
onClick={() => onOpenDrawer("finance")}
title="Открыть финансовую модель"
>
Подробнее
</button>
</div>
<div className={styles.bignumRow}>
{bignums.map((b) => (
<div key={b.label} className={styles.bignum}>
<div className={styles.bignumLabel}>{b.label}</div>
<div
className={`${styles.bignumValue} ${
b.isReal ? "" : styles.bignumValuePlaceholder
}`}
title={b.caption}
>
{b.value}
</div>
{b.unit && <span className={styles.bignumUnit}>{b.unit}</span>}
</div>
))}
</div>
<p className={styles.bignumCaption}>{caption}</p>
</div>
);
}

View file

@ -0,0 +1,55 @@
"use client";
/**
* LegalStatus bottom-grid «Legal Status» card. Renders ОКН / сервитуты /
* красные линии / аренда / ЗОУИТ / ограничения оборота as Нет/значение rows from
* `adaptLegalStatus`: REAL where /analyze provides it (ЗОУИТ count, красные
* линии, gate blockers), honest «» for the rest. "Подробнее" opens the legal
* drawer.
*/
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import type { ParcelAnalysis } from "@/types/site-finder";
import { adaptLegalStatus } from "@/components/site-finder/ptica/ptica-adapt";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
interface Props {
analysis: ParcelAnalysis;
onOpenDrawer: (key: DrawerKey) => void;
}
export function LegalStatus({ analysis, onOpenDrawer }: Props) {
const { rows } = adaptLegalStatus(analysis);
return (
<div className={styles.card}>
<div className={styles.cardHead}>
<h3 className={styles.cardTitle}>Legal Status</h3>
<button
type="button"
className={styles.detailBtn}
onClick={() => onOpenDrawer("legal")}
title="Открыть правовой статус"
>
Подробнее
</button>
</div>
<div className={styles.legalGrid}>
{rows.map((row) => (
<div key={row.k} className={styles.legalCell}>
<span className={styles.legalKey}>{row.k}</span>
<span
className={`${styles.legalValue} ${
row.field.isReal ? "" : styles.legalValuePlaceholder
}`}
title={row.field.caption}
>
{row.field.value}
</span>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,81 @@
"use client";
/**
* OksCard lower-grid «Объекты кап. строительства · ОКС» card. The backend does
* not surface ОКС on the parcel, so the card shows the prototype's isometric
* building visual + an honest empty-state «Нет данных» (no fabricated counts).
* The "Подробнее" button opens the oks drawer.
*/
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import { adaptOksLowerCard } from "@/components/site-finder/ptica/ptica-adapt";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
interface Props {
onOpenDrawer: (key: DrawerKey) => void;
}
export function OksCard({ onOpenDrawer }: Props) {
const card = adaptOksLowerCard();
return (
<div className={styles.card}>
<div className={styles.cardHead}>
<h3 className={styles.cardTitle}>
Объекты кап. строительства
<span className={styles.cardTitleSub}>ОКС</span>
</h3>
</div>
<div className={styles.isoBox}>
<svg viewBox="0 0 200 84" fill="none" aria-hidden="true">
<g opacity="0.9">
<path
d="M40 56 L70 42 L70 22 L40 36 Z"
className={styles.isoFaceLeft}
/>
<path
d="M70 42 L92 52 L92 32 L70 22 Z"
className={styles.isoFaceTop}
/>
<path
d="M40 56 L62 66 L92 52 L70 42 Z"
className={styles.isoFaceRight}
/>
<path
d="M100 50 L120 40 L120 26 L100 36 Z"
className={styles.isoFaceLeft}
/>
<path
d="M120 40 L138 48 L138 34 L120 26 Z"
className={styles.isoFaceTop}
/>
<path
d="M100 50 L118 58 L138 48 L120 40 Z"
className={styles.isoFaceRight}
/>
<path
d="M146 56 L162 47 L162 36 L146 45 Z"
className={styles.isoFaceLeft}
/>
<path
d="M146 56 L160 63 L176 54 L162 47 Z"
className={styles.isoFaceRight}
/>
</g>
</svg>
</div>
<div className={styles.emptyState}>{card.emptyState}</div>
<button
type="button"
className={`${styles.detailBtn} ${styles.detailBtnSpaced}`}
onClick={() => onOpenDrawer("oks")}
title="Открыть детализацию ОКС"
>
Подробнее
</button>
</div>
);
}

View file

@ -0,0 +1,37 @@
"use client";
/**
* PticaBottomGrid the prototype's `.bottom-grid` row, rendered below the lower
* grid: Investment Clearance · Buy Signal · Legal Status · Site Verdict. Each
* card wires to REAL /analyze + §22 data with honest placeholders.
*/
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import type { ParcelAnalysis } from "@/types/site-finder";
import type { ForecastReport } from "@/types/forecast";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
import { InvestmentClearance } from "./InvestmentClearance";
import { BuySignalCard } from "./BuySignalCard";
import { LegalStatus } from "./LegalStatus";
import { SiteVerdictCard } from "./SiteVerdictCard";
interface Props {
analysis: ParcelAnalysis;
forecastReport?: ForecastReport;
onOpenDrawer: (key: DrawerKey) => void;
}
export function PticaBottomGrid({
analysis,
forecastReport,
onOpenDrawer,
}: Props) {
return (
<section className={styles.bottomGrid}>
<InvestmentClearance onOpenDrawer={onOpenDrawer} />
<BuySignalCard forecastReport={forecastReport} />
<LegalStatus analysis={analysis} onOpenDrawer={onOpenDrawer} />
<SiteVerdictCard analysis={analysis} forecastReport={forecastReport} />
</section>
);
}

View file

@ -0,0 +1,44 @@
"use client";
/**
* PticaLowerGrid the prototype's `.lower-grid` row, rendered below the
* Development Scan: ОКС · Development Potential · Recommended Product ·
* Инсоляционная матрица. Each card wires to REAL /analyze + §22 data with honest
* placeholders and opens its detail drawer via `onOpenDrawer`.
*/
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import type { ParcelAnalysis } from "@/types/site-finder";
import type { ForecastReport } from "@/types/forecast";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
import { OksCard } from "./OksCard";
import { DevelopmentPotentialCard } from "./DevelopmentPotentialCard";
import { RecommendedProductCard } from "./RecommendedProductCard";
import { InsolationCard } from "./InsolationCard";
interface Props {
analysis: ParcelAnalysis;
forecastReport?: ForecastReport;
onOpenDrawer: (key: DrawerKey) => void;
}
export function PticaLowerGrid({
analysis,
forecastReport,
onOpenDrawer,
}: Props) {
return (
<section className={styles.lowerGrid}>
<OksCard onOpenDrawer={onOpenDrawer} />
<DevelopmentPotentialCard
analysis={analysis}
onOpenDrawer={onOpenDrawer}
/>
<RecommendedProductCard
forecastReport={forecastReport}
onOpenDrawer={onOpenDrawer}
/>
<InsolationCard onOpenDrawer={onOpenDrawer} />
</section>
);
}

View file

@ -0,0 +1,140 @@
"use client";
/**
* RecommendedProductCard lower-grid «Recommended Product» card. Renders the
* recommended класс (tag) + квартирография mix bars from the §22
* `forecastReport.product_tz`, reusing the exact bar logic of the Scenarios-tab
* `RecommendedProduct` (доля when an explicit `pct` mix exists, else
* |deficit_index| as signal strength). While the forecast is pending the
* honest «после прогноза» placeholder. "Подробнее" opens the product drawer.
*/
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import type { ForecastReport, ProductMixEntry } from "@/types/forecast";
import {
DEFICIT_BALANCE_EPS,
deficitTone,
fmtNum,
} from "@/components/site-finder/ptica/scenarios/scenario-helpers";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
interface Props {
/** §22 forecast report — quartirografiya comes from `product_tz`. */
forecastReport?: ForecastReport;
onOpenDrawer: (key: DrawerKey) => void;
}
const TONE_CLASS = {
good: styles.hbarFillGood,
bad: styles.hbarFillBad,
warn: styles.hbarFillBad,
neutral: styles.hbarFillNeutral,
} as const;
interface MixBar {
key: string;
label: string;
widthPct: number;
valueStr: string;
tone: "good" | "warn" | "bad" | "neutral";
}
function buildBars(mix: ProductMixEntry[]): {
bars: MixBar[];
byShare: boolean;
} {
const rows = mix.filter((m) => m.pct != null || m.deficit_index != null);
const byShare = rows.some((m) => m.pct != null);
const bars = rows.map((m, i): MixBar => {
if (m.pct != null) {
const pct = Math.max(0, Math.min(100, m.pct));
return {
key: `${m.bucket ?? "?"}-${m.obj_class ?? "?"}-${i}`,
label: m.bucket ?? "формат",
widthPct: pct,
valueStr: `${fmtNum(pct, pct % 1 === 0 ? 0 : 1)}%`,
tone: "neutral",
};
}
const di = m.deficit_index ?? 0;
const balanced = Math.abs(di) < DEFICIT_BALANCE_EPS;
return {
key: `${m.bucket ?? "?"}-${m.obj_class ?? "?"}-${i}`,
label: m.bucket ?? "формат",
widthPct: Math.max(0, Math.min(100, Math.abs(di) * 100)),
valueStr: `${di > 0 ? "+" : ""}${fmtNum(di, 2)}`,
tone: balanced ? "neutral" : deficitTone(di),
};
});
return { bars, byShare };
}
function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}
export function RecommendedProductCard({
forecastReport,
onOpenDrawer,
}: Props) {
const tz = forecastReport?.product_tz;
const hasProduct =
tz != null && (tz.obj_class != null || tz.mix.length > 0 || !!tz.summary);
const { bars, byShare } = tz
? buildBars(tz.mix)
: { bars: [], byShare: false };
return (
<div className={styles.card}>
<div className={styles.cardHead}>
<h3 className={styles.cardTitle}>
Recommended Product
<span className={styles.cardTitleSub}>продуктовая стратегия</span>
</h3>
{hasProduct && tz?.obj_class && (
<span className={styles.tag}>{capitalize(tz.obj_class)}</span>
)}
</div>
{hasProduct && bars.length > 0 ? (
<>
<div className={styles.mixHead}>
<span>Квартирография</span>
<span>{byShare ? "доля" : "сигнал"}</span>
</div>
<div className={styles.hbars}>
{bars.map((b) => (
<div key={b.key} className={styles.hbar}>
<span className={styles.hbarName}>{b.label}</span>
<div className={styles.hbarTrack}>
<div
className={`${styles.hbarFill} ${TONE_CLASS[b.tone]}`}
style={{ width: `${b.widthPct}%` }}
/>
</div>
<span className={styles.hbarValue}>{b.valueStr}</span>
</div>
))}
</div>
{!byShare && (
<p className={styles.panelHint}>
Длина полосы сила сигнала по индексу дефицита формата: зелёный
недонасыщенность, красный затоварка.
</p>
)}
</>
) : (
<div className={styles.emptyState}>после прогноза (Сценарии)</div>
)}
<button
type="button"
className={`${styles.detailBtn} ${styles.detailBtnSpaced}`}
onClick={() => onOpenDrawer("product")}
title="Открыть детализацию продукта"
>
Подробнее
</button>
</div>
);
}

View file

@ -0,0 +1,105 @@
"use client";
/**
* SiteVerdictCard bottom-grid «Site Verdict» plaque: the JTBD «стоит ли брать
* участок» answer. The big verdict word comes from gate_verdict
* (can_build_mkd Приобретать/Нельзя/Нужна проверка); the checklist is derived
* from REAL signals (рыночный спрос, потенциал застройки, инженерия) and the gate
* blockers/warnings. Ports the prototype's `.verdict-card`/`.verdict-split`.
*/
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import type { ParcelAnalysis } from "@/types/site-finder";
import type { ForecastReport } from "@/types/forecast";
import {
adaptSiteVerdict,
type PticaVerdictCheckItem,
} from "@/components/site-finder/ptica/ptica-adapt";
interface Props {
analysis: ParcelAnalysis;
forecastReport?: ForecastReport;
}
const WORD_TONE_CLASS = {
good: styles.verdictWordGood,
warn: styles.verdictWordWarn,
bad: styles.verdictWordBad,
} as const;
const ITEM_TONE_CLASS: Record<PticaVerdictCheckItem["tone"], string> = {
ok: styles.checkItemOk,
warn: styles.checkItemWarn,
bad: styles.checkItemBad,
};
function CheckIcon({ tone }: { tone: PticaVerdictCheckItem["tone"] }) {
if (tone === "ok") {
return (
<svg
className={styles.checkIc}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M5 13l4 4L19 7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
// warn / bad → triangle exclamation
return (
<svg
className={styles.checkIc}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M12 4l8 14H4L12 4z"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<path
d="M12 10v4M12 16.5v.4"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
);
}
export function SiteVerdictCard({ analysis, forecastReport }: Props) {
const { word, tone, checklist } = adaptSiteVerdict(analysis, forecastReport);
return (
<div className={`${styles.card} ${styles.verdictCard}`}>
<div className={styles.verdictSplit}>
<div className={styles.verdictLead}>
<div className={styles.verdictLeadLabel}>Site Verdict</div>
<div className={`${styles.verdictWord} ${WORD_TONE_CLASS[tone]}`}>
{word}
</div>
</div>
<div className={styles.checklist}>
{checklist.map((item, i) => (
<div
key={`${item.text}-${i}`}
className={`${styles.checkItem} ${ITEM_TONE_CLASS[item.tone]}`}
>
<CheckIcon tone={item.tone} />
{item.text}
</div>
))}
</div>
</div>
</div>
);
}

View file

@ -465,42 +465,7 @@ export function adaptEnvironmentCard(): PticaScanCard {
};
}
/** ОКС — empty-state for INCREMENT 1. */
export function adaptOksCard(): PticaScanCard {
return {
title: "ОКС",
rows: [],
emptyState: "Нет данных",
};
}
/**
* Инсоляция · 3D-масса entry card for the future3d / insolation drawer (the 3D
* massing + shadow preview). Buildability % is REAL (shared gauge); the rest are
* honest placeholders until the massing volumetrics land. The card exists so the
* 3D module is discoverable from the analysis grid, not only via the map tool.
*/
export function adaptInsolationCard(a: ParcelAnalysis): PticaScanCard {
const build = adaptBuildabilityGauge(a);
return {
title: "Инсоляция · 3D-масса",
badge: "3D",
rows: [
{
key: "Buildability",
field:
build.value != null && build.isReal
? real(`${Math.round(build.value)}%`)
: placeholder("после расчёта потенциала"),
},
{
key: "Норматив инсоляции",
field: placeholder("после 3D-расчёта теней"),
},
{ key: "Затенённость", field: placeholder("после 3D-расчёта теней") },
],
};
}
// (ОКС + Инсоляция moved to the lower-grid — see cockpit/OksCard + InsolationCard.)
// ══════════════════════════════════════════════════════════════════════════════
// DRAWER-LEVEL ADAPTERS (PR#4) — typed view-models, real-vs-placeholder centralized
@ -1584,6 +1549,444 @@ export function adaptOksDrawer(): PticaOksDrawer {
};
}
// ══════════════════════════════════════════════════════════════════════════════
// COCKPIT LOWER-GRID / BOTTOM-GRID SECTIONS — typed view-models for the cards
// rendered below DevelopmentScan (porting the prototype `.lower-grid` /
// `.bottom-grid`). Same {value, isReal, caption} honesty contract: REAL where the
// backend provides it, honest "—"/placeholder otherwise. No fabricated numbers.
// ══════════════════════════════════════════════════════════════════════════════
// ── ОКС (lower-grid card) — empty-state «Нет данных» ───────────────────────────
export interface PticaOksLowerCard {
rows: DrawerKvRow[];
/** When false the card shows the honest empty-state instead of rows. */
hasData: boolean;
emptyState: string;
}
/**
* ОКС on the parcel are not surfaced by /analyze, so this is an honest
* empty-state card. The isometric building visual is rendered by the React
* component; this adapter only carries the (currently empty) data + message.
*/
export function adaptOksLowerCard(): PticaOksLowerCard {
return {
rows: [
{ k: "ОКСы на участке", field: notReal("нет реестра ОКС (ЕГРН)") },
{ k: "Общая площадь", field: notReal("нет реестра ОКС (ЕГРН)") },
{ k: "Статус", field: notReal("нет реестра ОКС (ЕГРН)") },
],
hasData: false,
emptyState: "Нет данных",
};
}
// ── Development Potential (lower-grid card) — REAL (post-#1847) ─────────────────
/** One big-metric tile: a label, a value (split unit) + real/placeholder flag. */
export interface PticaPotentialMetric {
label: string;
value: string;
unit?: string;
isReal: boolean;
caption?: string;
/** Larger type for the two headline tiles (площадь, КСИТ). */
lead?: boolean;
}
export interface PticaPotentialCard {
metrics: PticaPotentialMetric[];
}
/**
* Development Potential REAL ёмкость-метрики из геометрии + НСПД-регламента,
* mirroring `adaptPotentialDrawer` but as the compact lower-grid tiles. Площадь
* участка (geometry), КСИТ (max_far), плотность = площадь×КСИТ, предельная
* высота, пятно = площадь×max_building_pct, продаваемая ёмкость по КСИТ.
* Honest «» (placeholder) where an input is absent.
*/
export function adaptPotentialCard(a: ParcelAnalysis): PticaPotentialCard {
const areaHa = a.geometry_suitability?.area_ha;
const areaM2 = parcelAreaM2(a);
const z = a.nspd_zoning;
const maxFar = z?.max_far;
const maxHeight = z?.max_height_m;
const buildingPct = z?.max_building_pct;
const areaMetric: PticaPotentialMetric =
areaM2 != null
? {
label: "Площадь участка",
value: formatInt(areaM2),
unit: "м²",
isReal: true,
lead: true,
}
: {
label: "Площадь участка",
value: PLACEHOLDER,
isReal: false,
caption: "нет геометрии",
lead: true,
};
const farMetric: PticaPotentialMetric =
maxFar != null
? {
label: "КСИТ",
value: formatFar(maxFar),
isReal: true,
caption: "НСПД",
lead: true,
}
: {
label: "КСИТ",
value: PLACEHOLDER,
isReal: false,
caption: SRC_NSPD_ZONING,
lead: true,
};
const densityMetric: PticaPotentialMetric =
maxFar != null && areaM2 != null
? {
label: "Плотность (КСИТ)",
value: formatInt(areaM2 * maxFar),
unit: "м²",
isReal: true,
caption: `площадь × КСИТ ${formatFar(maxFar)}`,
}
: {
label: "Плотность (КСИТ)",
value: PLACEHOLDER,
isReal: false,
caption: SRC_NSPD_ZONING,
};
const heightMetric: PticaPotentialMetric =
maxHeight != null
? {
label: "Предельная высота",
value: formatInt(maxHeight),
unit: "м",
isReal: true,
caption: "НСПД",
}
: {
label: "Предельная высота",
value: PLACEHOLDER,
isReal: false,
caption: SRC_NSPD_ZONING,
};
const spotMetric: PticaPotentialMetric =
areaM2 != null && buildingPct != null
? {
label: "Пятно застройки",
value: formatInt(areaM2 * (buildingPct / 100)),
unit: "м²",
isReal: true,
caption: `${formatInt(buildingPct)} % площади · НСПД`,
}
: {
label: "Пятно застройки",
value: PLACEHOLDER,
isReal: false,
caption: SRC_NSPD_ZONING,
};
const sellableMetric: PticaPotentialMetric =
maxFar != null && areaM2 != null
? {
label: "Продаваемая",
value: `${formatInt(areaM2 * maxFar)}`,
unit: "м²",
isReal: true,
caption: "оценка по КСИТ-ёмкости · без МОП/паркинга",
}
: {
label: "Продаваемая",
value: PLACEHOLDER,
isReal: false,
caption: "после расчёта потенциала",
};
return {
metrics: [
areaMetric,
farMetric,
densityMetric,
heightMetric,
spotMetric,
sellableMetric,
],
};
}
// ── Investment Clearance (bottom-grid) — finance bignums (PLACEHOLDER, §23) ─────
export interface PticaClearanceBignum {
label: string;
value: string;
unit?: string;
isReal: boolean;
caption?: string;
}
export interface PticaInvestmentClearance {
bignums: PticaClearanceBignum[];
caption: string;
}
/**
* Investment Clearance GDV/COST/PROFIT + ROI/IRR. The §23 finance model is NOT
* in the backend, so EVERY value is an honest placeholder «» (NO fabricated
* numbers). The bignum layout matches the prototype; the caption explains why.
*/
export function adaptInvestmentClearance(): PticaInvestmentClearance {
const fin = "после финмодели (§23)";
return {
bignums: [
{
label: "GDV · выручка",
value: PLACEHOLDER,
unit: "млрд ₽",
isReal: false,
caption: fin,
},
{
label: "Cost · затраты",
value: PLACEHOLDER,
unit: "млрд ₽",
isReal: false,
caption: fin,
},
{
label: "Profit · прибыль",
value: PLACEHOLDER,
unit: "млрд ₽",
isReal: false,
caption: fin,
},
{ label: "ROI", value: PLACEHOLDER, isReal: false, caption: fin },
{ label: "IRR", value: PLACEHOLDER, isReal: false, caption: fin },
],
caption: "после финмодели (§23)",
};
}
// ── Buy Signal (bottom-grid) — gauge from §22 forecast deficit_index ───────────
export interface PticaBuySignalCard {
gauge: PticaGauge;
/** The verdict word shown under the gauge (Приобретать / Наблюдать / Пропустить). */
word: string;
available: boolean;
caption: string;
}
/**
* Buy Signal derived from the §22 forecast exec-summary deficit_index, the SAME
* logic as `adaptInvestScore`'s buySignal (>0.05 приобретать / <0.05 пропустить /
* else наблюдать). The gauge value maps the (signed) deficit_index onto 0..100 for
* the visual; advisory (§22). While the forecast is pending «после прогноза».
*/
export function adaptBuySignalGauge(
report?: ForecastReport,
): PticaBuySignalCard {
if (!report) {
return {
gauge: {
value: null,
label: "после прогноза",
tone: "neutral",
isReal: false,
footnote: "Scenarios §22",
},
word: PLACEHOLDER,
available: false,
caption: "после прогноза (Scenarios)",
};
}
const di = report.exec_summary.key_numbers.deficit_index;
// Map signed deficit_index (roughly 0.5..+0.5) onto a 0..100 gauge value.
const gaugeValue =
di == null ? 50 : Math.max(0, Math.min(100, Math.round(50 + di * 100)));
let tone: PticaGauge["tone"] = "neutral";
let word = "Наблюдать";
if (di != null) {
if (di > 0.05) {
tone = "good";
word = "Приобретать";
} else if (di < -0.05) {
tone = "bad";
word = "Пропустить";
}
}
return {
gauge: {
value: gaugeValue,
label: word,
tone,
isReal: true,
footnote: "advisory · §22",
},
word,
available: true,
caption: "advisory · §22",
};
}
// ── Legal Status (bottom-grid) — ОКН / сервитуты / красные линии / ЗОУИТ ────────
export interface PticaLegalStatus {
rows: DrawerKvRow[];
}
/**
* Legal Status REAL where /analyze provides it (ЗОУИТ count, красные линии,
* gate verdict label), honest placeholder for the rest (ОКН / сервитуты /
* аренда / ограничения оборота / обременения not in the dump). Mirrors the
* prototype's Legal table (Нет / значение rows).
*/
export function adaptLegalStatus(a: ParcelAnalysis): PticaLegalStatus {
const zouit = a.nspd_zouit_overlaps ?? null;
const redLines = a.nspd_red_lines ?? null;
const gate = a.gate_verdict;
const rows: DrawerKvRow[] = [
{
k: "ЗОУИТ",
field:
zouit != null
? real(formatInt(zouit.length))
: notReal("источник НСПД"),
},
{
k: "Красные линии",
field:
redLines != null
? real(redLines.length > 0 ? formatInt(redLines.length) : "Нет")
: notReal("источник НСПД-граддок"),
},
{ k: "ОКН", field: notReal("источник НСПД") },
{ k: "Сервитуты", field: notReal("источник ЕГРН") },
{ k: "Аренда", field: notReal("источник ЕГРН") },
{
k: "Огранич. оборота",
field: gate
? real(
gate.blockers.length > 0
? `${formatInt(gate.blockers.length)} блок.`
: "Нет",
)
: notReal("источник ЕГРН"),
},
];
return { rows };
}
// ── Site Verdict (bottom-grid) — headline plaque + checklist ───────────────────
export interface PticaVerdictCheckItem {
text: string;
tone: "ok" | "warn" | "bad";
}
export interface PticaSiteVerdict {
/** The big verdict word (Приобретать / Можно / Нельзя / Нужна проверка). */
word: string;
tone: "good" | "warn" | "bad";
checklist: PticaVerdictCheckItem[];
}
/**
* Site Verdict the JTBD «стоит ли брать участок» answer. The verdict word comes
* from gate_verdict.can_build_mkd (true «Приобретать» green, false «Нельзя»
* red, "unknown" «Нужна проверка» amber). The checklist is derived from REAL
* signals where available (рыночный спрос via median price, потенциал застройки
* via max_far, инженерная обеспеченность via utilities count) and falls back to
* the gate blockers/warnings.
*/
export function adaptSiteVerdict(
a: ParcelAnalysis,
report?: ForecastReport,
): PticaSiteVerdict {
const gate = a.gate_verdict;
let word = "Нужна проверка";
let tone: PticaSiteVerdict["tone"] = "warn";
if (gate) {
if (gate.can_build_mkd === true) {
word = "Приобретать";
tone = "good";
} else if (gate.can_build_mkd === false) {
word = "Нельзя";
tone = "bad";
}
}
const checklist: PticaVerdictCheckItem[] = [];
// Рыночный спрос — derived from §22 deficit_index when ready, else from the
// presence of a district median (proxy «рынок отслеживается»).
const di = report?.exec_summary.key_numbers.deficit_index ?? null;
if (di != null) {
if (di > 0.05) {
checklist.push({ text: "Высокий спрос", tone: "ok" });
checklist.push({ text: "Дефицит предложения", tone: "ok" });
} else if (di < -0.05) {
checklist.push({ text: "Признаки затоварки рынка", tone: "warn" });
} else {
checklist.push({ text: "Рынок сбалансирован", tone: "warn" });
}
} else if (a.district?.median_price_per_m2 != null) {
checklist.push({ text: "Рынок района отслеживается", tone: "ok" });
}
// Потенциал застройки — REAL when КСИТ flows from НСПД.
if (a.nspd_zoning?.max_far != null) {
checklist.push({ text: "Потенциал застройки (КСИТ)", tone: "ok" });
} else {
checklist.push({ text: "Потенциал застройки не рассчитан", tone: "warn" });
}
// Инженерная обеспеченность — REAL from utilities count.
const utilCount = a.utilities?.summary?.length ?? 0;
if (utilCount >= 3) {
checklist.push({ text: "Хорошая инженерная обеспеченность", tone: "ok" });
} else if (utilCount > 0) {
checklist.push({
text: "Частичная инженерная обеспеченность",
tone: "warn",
});
} else {
checklist.push({ text: "Нет данных по инженерии", tone: "warn" });
}
// Gate blockers/warnings — surface the real risk items.
if (gate) {
for (const b of gate.blockers.slice(0, 2)) {
checklist.push({ text: b.detail || b.code, tone: "bad" });
}
for (const w of gate.warnings.slice(0, 2)) {
checklist.push({ text: w.detail || w.code, tone: "warn" });
}
}
// Always show at least one item so the plaque never reads empty.
if (checklist.length === 0) {
checklist.push({ text: "Недостаточно данных для чек-листа", tone: "warn" });
}
return { word, tone, checklist };
}
// ── Map geometry ──────────────────────────────────────────────────────────────
/** EKB fallback center (lat, lon). */