From 24b5e6047fa97f2ea6d446a49e838920e06efa44 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 21 Jun 2026 00:33:15 +0500 Subject: [PATCH] feat(ptica): complete cockpit lower-grid + bottom-grid sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../analysis/[cad]/ptica/PticaPageContent.tsx | 12 + .../analysis/[cad]/ptica/ptica.module.css | 318 +++++++++++- .../site-finder/ptica/BuildabilityGauge.tsx | 8 +- .../site-finder/ptica/DevelopmentScan.tsx | 18 +- .../ptica/cockpit/BuySignalCard.tsx | 30 ++ .../cockpit/DevelopmentPotentialCard.tsx | 57 +++ .../ptica/cockpit/InsolationCard.tsx | 56 +++ .../ptica/cockpit/InvestmentClearance.tsx | 59 +++ .../site-finder/ptica/cockpit/LegalStatus.tsx | 55 ++ .../site-finder/ptica/cockpit/OksCard.tsx | 81 +++ .../ptica/cockpit/PticaBottomGrid.tsx | 37 ++ .../ptica/cockpit/PticaLowerGrid.tsx | 44 ++ .../ptica/cockpit/RecommendedProductCard.tsx | 140 ++++++ .../ptica/cockpit/SiteVerdictCard.tsx | 105 ++++ .../site-finder/ptica/ptica-adapt.ts | 475 ++++++++++++++++-- 15 files changed, 1441 insertions(+), 54 deletions(-) create mode 100644 frontend/src/components/site-finder/ptica/cockpit/BuySignalCard.tsx create mode 100644 frontend/src/components/site-finder/ptica/cockpit/DevelopmentPotentialCard.tsx create mode 100644 frontend/src/components/site-finder/ptica/cockpit/InsolationCard.tsx create mode 100644 frontend/src/components/site-finder/ptica/cockpit/InvestmentClearance.tsx create mode 100644 frontend/src/components/site-finder/ptica/cockpit/LegalStatus.tsx create mode 100644 frontend/src/components/site-finder/ptica/cockpit/OksCard.tsx create mode 100644 frontend/src/components/site-finder/ptica/cockpit/PticaBottomGrid.tsx create mode 100644 frontend/src/components/site-finder/ptica/cockpit/PticaLowerGrid.tsx create mode 100644 frontend/src/components/site-finder/ptica/cockpit/RecommendedProductCard.tsx create mode 100644 frontend/src/components/site-finder/ptica/cockpit/SiteVerdictCard.tsx diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx index 684b1d61..7c08a200 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx @@ -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} /> + + )} {tab === "scenarios" && ( diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css index 58f46369..157f0a78 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css @@ -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); +} diff --git a/frontend/src/components/site-finder/ptica/BuildabilityGauge.tsx b/frontend/src/components/site-finder/ptica/BuildabilityGauge.tsx index 1bf46eb2..714bc38b 100644 --- a/frontend/src/components/site-finder/ptica/BuildabilityGauge.tsx +++ b/frontend/src/components/site-finder/ptica/BuildabilityGauge.tsx @@ -41,9 +41,11 @@ export function BuildabilityGauge({ gauge, title }: Props) { return (
-
- {title} -
+ {title && ( +
+ {title} +
+ )}
+

+ Buy Signal +

+ +
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/cockpit/DevelopmentPotentialCard.tsx b/frontend/src/components/site-finder/ptica/cockpit/DevelopmentPotentialCard.tsx new file mode 100644 index 00000000..27120cc5 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/cockpit/DevelopmentPotentialCard.tsx @@ -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 ( +
+
+

Development Potential

+
+ +
+ {metrics.map((m) => ( +
+
{m.label}
+
+ {m.value} + {m.unit && {m.unit}} +
+
+ ))} +
+ + +
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/cockpit/InsolationCard.tsx b/frontend/src/components/site-finder/ptica/cockpit/InsolationCard.tsx new file mode 100644 index 00000000..868007d2 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/cockpit/InsolationCard.tsx @@ -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 ( +
+
+

Инсоляционная матрица

+ превью +
+ +
+ +
+ +
+
ПРЕВЬЮ
+

Тени по времени суток — в 3D-модуле массы застройки.

+ +
+
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/cockpit/InvestmentClearance.tsx b/frontend/src/components/site-finder/ptica/cockpit/InvestmentClearance.tsx new file mode 100644 index 00000000..8681b887 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/cockpit/InvestmentClearance.tsx @@ -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 ( +
+
+

+ Investment Clearance + финансовая модель +

+ +
+ +
+ {bignums.map((b) => ( +
+
{b.label}
+
+ {b.value} +
+ {b.unit && {b.unit}} +
+ ))} +
+ +

{caption}

+
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/cockpit/LegalStatus.tsx b/frontend/src/components/site-finder/ptica/cockpit/LegalStatus.tsx new file mode 100644 index 00000000..3b8011dc --- /dev/null +++ b/frontend/src/components/site-finder/ptica/cockpit/LegalStatus.tsx @@ -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 ( +
+
+

Legal Status

+ +
+ +
+ {rows.map((row) => ( +
+ {row.k} + + {row.field.value} + +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/cockpit/OksCard.tsx b/frontend/src/components/site-finder/ptica/cockpit/OksCard.tsx new file mode 100644 index 00000000..0ecb8df3 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/cockpit/OksCard.tsx @@ -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 ( +
+
+

+ Объекты кап. строительства + ОКС +

+
+ +
+ +
+ +
{card.emptyState}
+ + +
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/cockpit/PticaBottomGrid.tsx b/frontend/src/components/site-finder/ptica/cockpit/PticaBottomGrid.tsx new file mode 100644 index 00000000..f1b9951b --- /dev/null +++ b/frontend/src/components/site-finder/ptica/cockpit/PticaBottomGrid.tsx @@ -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 ( +
+ + + + +
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/cockpit/PticaLowerGrid.tsx b/frontend/src/components/site-finder/ptica/cockpit/PticaLowerGrid.tsx new file mode 100644 index 00000000..253af901 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/cockpit/PticaLowerGrid.tsx @@ -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 ( +
+ + + + +
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/cockpit/RecommendedProductCard.tsx b/frontend/src/components/site-finder/ptica/cockpit/RecommendedProductCard.tsx new file mode 100644 index 00000000..d0e60963 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/cockpit/RecommendedProductCard.tsx @@ -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 ( +
+
+

+ Recommended Product + продуктовая стратегия +

+ {hasProduct && tz?.obj_class && ( + {capitalize(tz.obj_class)} + )} +
+ + {hasProduct && bars.length > 0 ? ( + <> +
+ Квартирография + {byShare ? "доля" : "сигнал"} +
+
+ {bars.map((b) => ( +
+ {b.label} +
+
+
+ {b.valueStr} +
+ ))} +
+ {!byShare && ( +

+ Длина полосы — сила сигнала по индексу дефицита формата: зелёный — + недонасыщенность, красный — затоварка. +

+ )} + + ) : ( +
после прогноза (Сценарии)
+ )} + + +
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/cockpit/SiteVerdictCard.tsx b/frontend/src/components/site-finder/ptica/cockpit/SiteVerdictCard.tsx new file mode 100644 index 00000000..4d8012d7 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/cockpit/SiteVerdictCard.tsx @@ -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 = { + ok: styles.checkItemOk, + warn: styles.checkItemWarn, + bad: styles.checkItemBad, +}; + +function CheckIcon({ tone }: { tone: PticaVerdictCheckItem["tone"] }) { + if (tone === "ok") { + return ( + + ); + } + // warn / bad → triangle exclamation + return ( + + ); +} + +export function SiteVerdictCard({ analysis, forecastReport }: Props) { + const { word, tone, checklist } = adaptSiteVerdict(analysis, forecastReport); + + return ( +
+
+
+
Site Verdict
+
+ {word} +
+
+
+ {checklist.map((item, i) => ( +
+ + {item.text} +
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/ptica-adapt.ts b/frontend/src/components/site-finder/ptica/ptica-adapt.ts index 26e81114..5a784a2d 100644 --- a/frontend/src/components/site-finder/ptica/ptica-adapt.ts +++ b/frontend/src/components/site-finder/ptica/ptica-adapt.ts @@ -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). */