+
+
+
+
+
+
+
+ {/* ROW 1 — map · passport · gauges */}
+
+
+ {/* ROW 2 — 6 scan cards */}
+
+
+ {/* ROW 3 — ОКС · potential · product · insolation */}
+
+
+ {/* ROW 4 — clearance · legal · verdict */}
+
+
+ {/* Среда · экология — honest atmospheric scan (not in the prototype
+ 4-row grid but ported from the adapter so the data isn't dropped). */}
+
+
+
+
+
+
+
+ МОДЕЛЬ ОЦЕНКИ: ПТИЦА · Site Finder v2
+
+ ·
+
+ УЧАСТОК: {analysis.cad_num}
+
+
+
+ НЕ ЯВЛЯЕТСЯ ПУБЛИЧНОЙ ОФЕРТОЙ · ИНФОРМАЦИЯ НОСИТ ОЦЕНОЧНЫЙ ХАРАКТЕР
+
+
+
+
+ {/* Theme toggle */}
+
+ {theme === "light" ? "☾" : "☀"}
+
+
+ {/* Chat FAB */}
+
+
+
+
+
+
+
+ {/* Shared scrim */}
+
{
+ handleCloseDrawer();
+ closeChat();
+ }}
+ aria-hidden="true"
+ />
+
+ {/* Drawer */}
+
+ {drawerEntry?.content}
+
+
+ {/* Chat */}
+
+
+ {/* Toast */}
+ {toast && (
+
{toast}
+ )}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/ptica-v2/V2Drawer.tsx b/frontend/src/components/site-finder/ptica-v2/V2Drawer.tsx
new file mode 100644
index 00000000..39ef3d5c
--- /dev/null
+++ b/frontend/src/components/site-finder/ptica-v2/V2Drawer.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+/**
+ * V2Drawer — right slide-in panel (prototype `.drawer`, 480px). Controlled by
+ * `open`; renders the eyebrow / title / summary header + scrollable body. The
+ * shared scrim is owned by V2Cockpit (so it can also back the chat panel).
+ */
+
+import type { ReactNode } from "react";
+
+import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
+
+interface Props {
+ open: boolean;
+ eyebrow?: string;
+ title: string;
+ sum?: string;
+ onClose: () => void;
+ children: ReactNode;
+}
+
+export function V2Drawer({ open, eyebrow, title, sum, onClose, children }: Props) {
+ return (
+
+
+
+ {eyebrow &&
{eyebrow}
}
+
{title}
+ {sum &&
{sum}
}
+
+
+ ✕
+
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/ptica-v2/V2DrawerPrimitives.tsx b/frontend/src/components/site-finder/ptica-v2/V2DrawerPrimitives.tsx
new file mode 100644
index 00000000..6b593bc6
--- /dev/null
+++ b/frontend/src/components/site-finder/ptica-v2/V2DrawerPrimitives.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+/**
+ * V2DrawerPrimitives — shared drawer-content building blocks (prototype `.dsec` /
+ * `.drow` / `.dsource` / `.dfac`). Each consumes the ptica-adapt view-models so
+ * REAL data renders normally and placeholders render muted with their caption as
+ * a title tooltip.
+ */
+
+import type { ReactNode } from "react";
+
+import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
+import type {
+ DrawerKvRow,
+ PticaFactorBar,
+} from "@/components/site-finder/ptica/ptica-adapt";
+import { V2ShieldIcon } from "@/components/site-finder/ptica-v2/V2Icons";
+
+export function DSec({
+ title,
+ badge,
+ children,
+}: {
+ title: string;
+ badge?: ReactNode;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {title}
+ {badge}
+
+ {children}
+
+ );
+}
+
+export function DRow({ row }: { row: DrawerKvRow }) {
+ return (
+
+ {row.k}
+
+ {row.field.value}
+
+
+ );
+}
+
+export function DRows({ rows }: { rows: DrawerKvRow[] }) {
+ return (
+ <>
+ {rows.map((r) => (
+
+ ))}
+ >
+ );
+}
+
+const BAR_COLOR: Record
= {
+ good: "var(--accent-green)",
+ bad: "var(--accent-red)",
+ neutral: "var(--accent-cyan)",
+};
+
+export function DFactor({ bar }: { bar: PticaFactorBar }) {
+ const pct = Math.max(0, Math.min(100, bar.pct));
+ return (
+
+
+ {bar.name}
+ {bar.value}
+
+
+
+
+
+ );
+}
+
+export function DNote({ children }: { children: ReactNode }) {
+ return {children}
;
+}
+
+export function DSource({ children }: { children: ReactNode }) {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/ptica-v2/V2Gauge.tsx b/frontend/src/components/site-finder/ptica-v2/V2Gauge.tsx
new file mode 100644
index 00000000..84eece96
--- /dev/null
+++ b/frontend/src/components/site-finder/ptica-v2/V2Gauge.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+/**
+ * V2Gauge — SVG progress ring (prototype `.gauge-wrap` / `.minigauge`). Renders a
+ * faint track + a colored arc whose length maps a 0..100 value. `null` value →
+ * muted "—" center (honest no-data, never a fake percentage). Tone selects the
+ * arc color via the scoped CSS tokens.
+ *
+ * Geometry mirrors the prototype: r=50 on a 120-box (big), r=30 on a 74-box
+ * (mini), r=26 on a 66-box (buy). dashoffset = circumference × (1 − value/100).
+ */
+
+import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
+import type { PticaGauge } from "@/components/site-finder/ptica/ptica-adapt";
+
+type Size = "big" | "mini" | "buy";
+
+const GEOM: Record = {
+ big: { box: 120, r: 50, stroke: 9 },
+ mini: { box: 74, r: 30, stroke: 7 },
+ buy: { box: 66, r: 26, stroke: 7 },
+};
+
+// Tone → stroke color (map exception: Leaflet/SVG can't read CSS custom props in
+// stroke attr reliably across themes, so we reference the var() here directly).
+const TONE_STROKE: Record = {
+ good: "var(--accent-green)",
+ warn: "var(--accent-yellow)",
+ bad: "var(--accent-red)",
+ neutral: "var(--accent-cyan)",
+};
+
+interface Props {
+ gauge: PticaGauge;
+ size?: Size;
+}
+
+export function V2Gauge({ gauge, size = "big" }: Props) {
+ const { box, r, stroke } = GEOM[size];
+ const c = box / 2;
+ const circ = 2 * Math.PI * r;
+ const pct = gauge.value == null ? 0 : Math.max(0, Math.min(100, gauge.value));
+ const offset = circ * (1 - pct / 100);
+ const color = gauge.value == null ? "var(--text-soft)" : TONE_STROKE[gauge.tone];
+
+ return (
+
+
+ {gauge.value != null && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/site-finder/ptica-v2/V2Icons.tsx b/frontend/src/components/site-finder/ptica-v2/V2Icons.tsx
new file mode 100644
index 00000000..dceb7815
--- /dev/null
+++ b/frontend/src/components/site-finder/ptica-v2/V2Icons.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+/**
+ * V2Icons — inline SVG glyphs ported 1:1 from the prototype (ptica-v2/index.html).
+ * Kept in one module so the topbar / rail / drawers / chat share identical marks.
+ */
+
+import type { ReactNode } from "react";
+
+export function V2BirdMark(): ReactNode {
+ return (
+
+
+
+ );
+}
+
+export function V2CheckIcon(): ReactNode {
+ return (
+
+
+
+ );
+}
+
+export function V2WarnIcon(): ReactNode {
+ return (
+
+
+
+
+ );
+}
+
+export function V2ShieldIcon(): ReactNode {
+ return (
+
+
+
+ );
+}
+
+export function V2CopyIcon(): ReactNode {
+ return (
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/site-finder/ptica-v2/V2LowerGrid.tsx b/frontend/src/components/site-finder/ptica-v2/V2LowerGrid.tsx
new file mode 100644
index 00000000..d12c4b5e
--- /dev/null
+++ b/frontend/src/components/site-finder/ptica-v2/V2LowerGrid.tsx
@@ -0,0 +1,201 @@
+"use client";
+
+/**
+ * V2LowerGrid — row 3 (prototype `.row3`): ОКС · Development Potential ·
+ * Recommended Product · Инсоляционная матрица. ОКС is an honest empty-state (no
+ * реестр ОКС in /analyze); Potential reads REAL ёмкость from геометрия + НСПД;
+ * Product reads the §22 product_tz (class + quartile mix) or shows «после
+ * прогноза»; Insolation stays «скоро» (not computed yet).
+ */
+
+import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
+import type { ParcelAnalysis } from "@/types/site-finder";
+import type { ForecastReport } from "@/types/forecast";
+import {
+ adaptOksLowerCard,
+ adaptPotentialCard,
+ adaptProductDrawer,
+} from "@/components/site-finder/ptica/ptica-adapt";
+import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry";
+
+interface Props {
+ analysis: ParcelAnalysis;
+ forecastReport?: ForecastReport;
+ onOpenDrawer: (key: V2DrawerKey) => void;
+}
+
+export function V2LowerGrid({ analysis, forecastReport, onOpenDrawer }: Props) {
+ const oks = adaptOksLowerCard();
+ const potential = adaptPotentialCard(analysis);
+ const product = adaptProductDrawer(forecastReport);
+
+ return (
+
+ {/* ОКС */}
+
+
+
+ ОКС
+ объекты кап. стр-ва
+
+
+
+
+
+
+
+
+
+
+ {oks.rows.map((r) => (
+
+ {r.k}
+
+ {r.field.value}
+
+
+ ))}
+
+
+
+ onOpenDrawer("oks")}
+ >
+ ПОДРОБНЕЕ →
+
+
+
+
+
+ {/* Development Potential */}
+
+
+
+ Development Potential
+
+
+
+
+ {potential.metrics.map((m) => (
+
+
{m.label}
+
+ {m.value}
+ {m.unit && {m.unit} }
+
+
+ ))}
+
+
+ onOpenDrawer("potential")}
+ >
+ ПОДРОБНЕЕ →
+
+
+
+
+
+ {/* Recommended Product */}
+
+
+
+ Recommended Product
+ продуктовая стратегия
+
+
+
+
+ Класс
+
+ {product.objClass.value}
+
+
+ {product.available && product.mix.length > 0 ? (
+ <>
+
Квартирография
+
+ {product.mix.map((m) => (
+
+ {m.bucket}
+
+
+
+ {m.pct}%
+
+ ))}
+
+ >
+ ) : (
+
после прогноза (Сценарии)
+ )}
+
+ onOpenDrawer("product")}
+ >
+ ПОДРОБНЕЕ →
+
+
+
+
+
+ {/* Insolation matrix — «скоро» */}
+
+
+
+ Инсоляционная матрица
+
+
+
+
+
+
СКОРО
+
+ Расчёт инсоляции и КЕО в следующей версии
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/site-finder/ptica-v2/V2Map.tsx b/frontend/src/components/site-finder/ptica-v2/V2Map.tsx
new file mode 100644
index 00000000..eeb8c383
--- /dev/null
+++ b/frontend/src/components/site-finder/ptica-v2/V2Map.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+/**
+ * V2Map — the cockpit map card. Reuses the existing rich Leaflet plumbing
+ * (PticaMapInner — parcel polygon, POI / ЖК / pipeline glyph pins, ЗОУИТ zones,
+ * connection-point lines, custom POI, dark/satellite tiles) via a SINGLE shared
+ * implementation loaded through dynamic(ssr:false) so Leaflet never touches the
+ * server. Connection points + custom POIs are fetched here (same TanStack hooks
+ * as PticaMap) and threaded into the inner map.
+ *
+ * The inner map brings its own overlay/legend/controls + base-layer toggle
+ * (Спутник | Схема) and dark CartoDB tiles, matching the prototype map block.
+ * The map title chip is rendered here over the card per the prototype `.map-head`.
+ */
+
+import dynamic from "next/dynamic";
+
+import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
+import type { ParcelAnalysis } from "@/types/site-finder";
+import { useConnectionPoints } from "@/hooks/useConnectionPoints";
+import { useCustomPois } from "@/hooks/useCustomPois";
+
+const PticaMapInner = dynamic(
+ () => import("@/components/site-finder/ptica/PticaMapInner"),
+ {
+ ssr: false,
+ loading: () => Загрузка карты…
,
+ },
+);
+
+interface Props {
+ analysis: ParcelAnalysis;
+}
+
+export function V2Map({ analysis }: Props) {
+ const cad = analysis.cad_num;
+ const { data: connectionPoints } = useConnectionPoints(cad);
+ const { data: customPois } = useCustomPois(cad);
+
+ return (
+
+
+
+
+
+ Карта участка
+
+
+ );
+}
diff --git a/frontend/src/components/site-finder/ptica-v2/V2Passport.tsx b/frontend/src/components/site-finder/ptica-v2/V2Passport.tsx
new file mode 100644
index 00000000..d86d7c93
--- /dev/null
+++ b/frontend/src/components/site-finder/ptica-v2/V2Passport.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+/**
+ * V2Passport — «Паспорт участка» card (prototype `.passport`). Renders REAL ЕГРН
+ * fields via adaptPassport; absent fields show the honest «—» placeholder. The
+ * "ПОДРОБНЕЕ →" link opens the passport drawer.
+ */
+
+import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
+import type { ParcelAnalysis } from "@/types/site-finder";
+import { adaptPassport } from "@/components/site-finder/ptica/ptica-adapt";
+import type { PticaField } from "@/components/site-finder/ptica/ptica-adapt";
+import { V2CopyIcon } from "@/components/site-finder/ptica-v2/V2Icons";
+import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry";
+
+function Cell({
+ label,
+ field,
+ wide,
+ big,
+}: {
+ label: string;
+ field: PticaField;
+ wide?: boolean;
+ big?: boolean;
+}) {
+ return (
+
+
{label}
+
+ {field.value}
+
+
+ );
+}
+
+interface Props {
+ analysis: ParcelAnalysis;
+ onOpenDrawer: (key: V2DrawerKey) => void;
+}
+
+export function V2Passport({ analysis, onOpenDrawer }: Props) {
+ const p = adaptPassport(analysis);
+
+ return (
+
+
+
+ Паспорт участка
+
+
onOpenDrawer("parcel")}
+ >
+ ПОДРОБНЕЕ →
+
+
+
+
+ ID
+ {p.cadNum}
+
+
+
+
+
+
|
+
|
+
|
+
|
+
|
+
+
Статус
+
+ {p.status.value}
+
+
+
|
+
+
+
+ );
+}
diff --git a/frontend/src/components/site-finder/ptica-v2/V2Rail.tsx b/frontend/src/components/site-finder/ptica-v2/V2Rail.tsx
new file mode 100644
index 00000000..ca99aa57
--- /dev/null
+++ b/frontend/src/components/site-finder/ptica-v2/V2Rail.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+/**
+ * V2Rail — left icon rail (prototype `.rail`). Each item opens its detail drawer
+ * via the shared `onOpenDrawer` callback (V2 drawer registry keys). The currently
+ * open drawer key highlights the matching rail item (prototype `.rail-on`).
+ */
+
+import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
+import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry";
+
+interface RailItem {
+ key: string;
+ label: string;
+ drawer: V2DrawerKey;
+ icon: React.ReactNode;
+}
+
+const RAIL: RailItem[] = [
+ {
+ key: "parcel",
+ label: "Участок",
+ drawer: "parcel",
+ icon: (
+
+
+
+
+ ),
+ },
+ {
+ key: "potential",
+ label: "Потенциал",
+ drawer: "potential",
+ icon: (
+
+
+
+
+ ),
+ },
+ {
+ key: "product",
+ label: "Продукт",
+ drawer: "product",
+ icon: (
+
+
+
+
+ ),
+ },
+ {
+ key: "economics",
+ label: "Экономика",
+ drawer: "economics",
+ icon: (
+
+
+
+ ),
+ },
+ {
+ key: "risks",
+ label: "Риски",
+ drawer: "risks",
+ icon: (
+
+
+
+
+
+ ),
+ },
+ {
+ key: "compare",
+ label: "Сравнение",
+ drawer: "compare",
+ icon: (
+
+
+
+ ),
+ },
+ {
+ key: "reports",
+ label: "Отчёты",
+ drawer: "reports",
+ icon: (
+
+
+
+
+ ),
+ },
+];
+
+interface Props {
+ openDrawer: V2DrawerKey | null;
+ onOpenDrawer: (key: V2DrawerKey) => void;
+}
+
+export function V2Rail({ openDrawer, onOpenDrawer }: Props) {
+ return (
+
+ {RAIL.map((item) => {
+ const active = openDrawer === item.drawer;
+ return (
+ onOpenDrawer(item.drawer)}
+ >
+ {item.icon}
+ {item.label}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/frontend/src/components/site-finder/ptica-v2/V2RightColumn.tsx b/frontend/src/components/site-finder/ptica-v2/V2RightColumn.tsx
new file mode 100644
index 00000000..558a4f33
--- /dev/null
+++ b/frontend/src/components/site-finder/ptica-v2/V2RightColumn.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+/**
+ * V2RightColumn — row-1 right stack (prototype `.col-stack`): the Buildability
+ * gauge (SVG ring) over the Инвест-скор block. Both honest: the gauge shows a
+ * muted «—» when there is no data; the invest-score shows «—» until the §22
+ * forecast surfaces the overall_score. Values flow through ptica-adapt.
+ */
+
+import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
+import type { ParcelAnalysis } from "@/types/site-finder";
+import type { ForecastReport } from "@/types/forecast";
+import {
+ adaptBuildabilityGauge,
+ adaptInvestScore,
+} from "@/components/site-finder/ptica/ptica-adapt";
+import type { PticaValueTone } from "@/components/site-finder/ptica/ptica-adapt";
+import { V2Gauge } from "@/components/site-finder/ptica-v2/V2Gauge";
+import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry";
+
+const TONE_CLASS: Record = {
+ good: "",
+ warn: "amb",
+ none: "none",
+};
+
+// Static spark bars (decorative only — prototype `.spark`).
+const SPARK = [12, 16, 20, 15, 22, 17, 24];
+
+interface Props {
+ analysis: ParcelAnalysis;
+ forecastReport?: ForecastReport;
+ onOpenDrawer: (key: V2DrawerKey) => void;
+}
+
+export function V2RightColumn({ analysis, forecastReport, onOpenDrawer }: Props) {
+ const gauge = adaptBuildabilityGauge(analysis);
+ const invest = adaptInvestScore(analysis, forecastReport);
+ const pct = gauge.value == null ? "—" : `${gauge.value}%`;
+ const gaugeColor =
+ gauge.value == null
+ ? "var(--text-soft)"
+ : gauge.tone === "good"
+ ? "var(--accent-green)"
+ : gauge.tone === "warn"
+ ? "var(--accent-yellow)"
+ : gauge.tone === "bad"
+ ? "var(--accent-red)"
+ : "var(--accent-cyan)";
+
+ return (
+
+ {/* Buildability gauge */}
+
+
+
+
+
+ Buildability Index
+
+
+
+
+
+
+
+ {pct}
+
+
+ {gauge.label}
+
+
+
+ {gauge.footnote && (
+
{gauge.footnote}
+ )}
+
+
+
+ {/* Invest score */}
+
+
+
+ Инвест-скор
+
+
onOpenDrawer("economics")}
+ >
+ ПОДРОБНЕЕ →
+
+
+
+
+
+ {invest.score.value}
+ /10
+
+
+
+ {SPARK.map((h, i) => (
+
+ ))}
+
+
+
+
+
+
Потенциал
+
+ {invest.potential.value}
+
+
+
+
Риск
+
+ {invest.risk.value}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/site-finder/ptica-v2/V2RiskCard.tsx b/frontend/src/components/site-finder/ptica-v2/V2RiskCard.tsx
new file mode 100644
index 00000000..935316bc
--- /dev/null
+++ b/frontend/src/components/site-finder/ptica-v2/V2RiskCard.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+/**
+ * V2RiskCard — the «Риски» mini-gauge card (prototype `.risk-card`). Renders the
+ * derived risk proxy (adaptRiskGauge, «предв.») as a small SVG ring + label;
+ * muted «—» when there is no data. Opens the risks drawer.
+ */
+
+import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
+import type { ParcelAnalysis } from "@/types/site-finder";
+import { adaptRiskGauge } from "@/components/site-finder/ptica/ptica-adapt";
+import { V2Gauge } from "@/components/site-finder/ptica-v2/V2Gauge";
+import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry";
+
+interface Props {
+ analysis: ParcelAnalysis;
+ onOpenDrawer: (key: V2DrawerKey) => void;
+}
+
+export function V2RiskCard({ analysis, onOpenDrawer }: Props) {
+ const gauge = adaptRiskGauge(analysis);
+ const pct = gauge.value == null ? "—" : `${gauge.value}%`;
+ const color =
+ gauge.value == null
+ ? "var(--text-soft)"
+ : gauge.tone === "good"
+ ? "var(--accent-green)"
+ : gauge.tone === "warn"
+ ? "var(--accent-yellow)"
+ : "var(--accent-red)";
+
+ return (
+
+
+
+
Сводный риск
+
+
+ {gauge.label}
+
+
onOpenDrawer("risks")}
+ >
+ ПОДРОБНЕЕ →
+
+
+
+ );
+}
diff --git a/frontend/src/components/site-finder/ptica-v2/V2ScanCard.tsx b/frontend/src/components/site-finder/ptica-v2/V2ScanCard.tsx
new file mode 100644
index 00000000..3219335c
--- /dev/null
+++ b/frontend/src/components/site-finder/ptica-v2/V2ScanCard.tsx
@@ -0,0 +1,116 @@
+"use client";
+
+/**
+ * V2ScanCard — generic «scan» card for row 2 (prototype `.row2 .card`). Renders a
+ * PticaScanCard view-model: a title (+ optional badge), kv-rows OR status-rows
+ * (dot + state, used by Инженерия), an empty-state, and a "ПОДРОБНЕЕ →" link to
+ * the card's drawer. All values come from the ptica-adapt layer (REAL or honest
+ * «—»).
+ */
+
+import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
+import type {
+ PticaScanCard,
+ PticaScanRow,
+ PticaStatusTone,
+} from "@/components/site-finder/ptica/ptica-adapt";
+import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry";
+
+const STATUS_COLOR: Record = {
+ ok: "var(--accent-green)",
+ warn: "var(--accent-orange)",
+ bad: "var(--accent-red)",
+};
+
+const STATUS_LABEL: Record = {
+ ok: "Доступно",
+ warn: "Огранич.",
+ bad: "Нет",
+};
+
+const STATUS_CLASS: Record = {
+ ok: styles.ok,
+ warn: styles.warnc,
+ bad: styles.warnc,
+};
+
+function KvRow({ row }: { row: PticaScanRow }) {
+ return (
+
+ {row.key}
+
+ {row.field.value}
+
+
+ );
+}
+
+function StatusRow({ row }: { row: PticaScanRow }) {
+ const tone = row.status ?? "ok";
+ return (
+
+
+
+ {row.key}
+
+
+
+ {STATUS_LABEL[tone]}
+
+
+ {row.field.value}
+
+
+
+ );
+}
+
+interface Props {
+ card: PticaScanCard;
+ drawer: V2DrawerKey;
+ onOpenDrawer: (key: V2DrawerKey) => void;
+}
+
+export function V2ScanCard({ card, drawer, onOpenDrawer }: Props) {
+ return (
+
+
+
+ {card.title}
+ {card.badge && {card.badge} }
+
+
+
+ {card.rows.length === 0 ? (
+
+ {card.emptyState ?? "Нет данных"}
+
+ ) : (
+ card.rows.map((row) =>
+ row.status ? (
+
+ ) : (
+
+ ),
+ )
+ )}
+
+ onOpenDrawer(drawer)}
+ >
+ ПОДРОБНЕЕ →
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/site-finder/ptica-v2/V2Topbar.tsx b/frontend/src/components/site-finder/ptica-v2/V2Topbar.tsx
new file mode 100644
index 00000000..bb373281
--- /dev/null
+++ b/frontend/src/components/site-finder/ptica-v2/V2Topbar.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+/**
+ * V2Topbar — cockpit top bar (prototype `.topbar`): ПТИЦА wordmark (links to
+ * Site Finder), 4 режимных tabs (visual — the cockpit is a single analysis view),
+ * a live clock + version chip + two icon buttons.
+ *
+ * The clock is mounted-only (state gated by an effect) to avoid an SSR/client
+ * hydration mismatch; the effect drives a UI clock — no HTTP — which is the
+ * sanctioned use of useEffect here.
+ */
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+
+import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
+import { V2BirdMark } from "@/components/site-finder/ptica-v2/V2Icons";
+
+const TABS = [
+ "Анализ участка",
+ "Сценарии",
+ "Отчёты",
+ "Сравнение",
+] as const;
+
+interface Props {
+ cad: string;
+}
+
+export function V2Topbar({ cad }: Props) {
+ const [now, setNow] = useState<{ time: string; date: string } | null>(null);
+
+ useEffect(() => {
+ function tick() {
+ const d = new Date();
+ const pad = (n: number) => String(n).padStart(2, "0");
+ setNow({
+ time: `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`,
+ date: `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())}`,
+ });
+ }
+ tick();
+ const id = window.setInterval(tick, 1000);
+ return () => window.clearInterval(id);
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/site-finder/ptica-v2/v2-drawer-registry.tsx b/frontend/src/components/site-finder/ptica-v2/v2-drawer-registry.tsx
new file mode 100644
index 00000000..c3b2e997
--- /dev/null
+++ b/frontend/src/components/site-finder/ptica-v2/v2-drawer-registry.tsx
@@ -0,0 +1,465 @@
+"use client";
+
+/**
+ * v2-drawer-registry — the 11 detail drawers of the v2 cockpit (prototype
+ * `DRAWERS`). Each entry = { rail?, eyebrow, title, sum, content }. Content is
+ * built from the ptica-adapt drawer view-models so REAL data renders and absent
+ * fields stay honest «—». Mirrors the prototype 1:1 in copy + layout; demo
+ * numbers from the prototype are NOT hard-coded — values come from /analyze.
+ */
+
+import type { ReactNode } from "react";
+
+import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
+import type { ParcelAnalysis } from "@/types/site-finder";
+import type { ForecastReport } from "@/types/forecast";
+import {
+ adaptPassportDrawer,
+ adaptPotentialDrawer,
+ adaptBuildabilityDrawer,
+ adaptProductDrawer,
+ adaptEconomyDrawer,
+ adaptRisksDrawer,
+ adaptRestrictionsDrawer,
+ adaptEngineeringDrawer,
+ adaptMarketDrawer,
+ adaptOksDrawer,
+} from "@/components/site-finder/ptica/ptica-adapt";
+import {
+ DSec,
+ DRow,
+ DRows,
+ DFactor,
+ DNote,
+ DSource,
+} from "@/components/site-finder/ptica-v2/V2DrawerPrimitives";
+
+export const V2_DRAWER_KEYS = [
+ "parcel",
+ "potential",
+ "product",
+ "economics",
+ "risks",
+ "compare",
+ "reports",
+ "restrictions",
+ "engineering",
+ "market",
+ "oks",
+] as const;
+
+export type V2DrawerKey = (typeof V2_DRAWER_KEYS)[number];
+
+export function asV2DrawerKey(
+ value: string | null | undefined,
+): V2DrawerKey | null {
+ if (!value) return null;
+ return (V2_DRAWER_KEYS as readonly string[]).includes(value)
+ ? (value as V2DrawerKey)
+ : null;
+}
+
+export interface V2DrawerEntry {
+ eyebrow: string;
+ title: string;
+ sum: string;
+ content: ReactNode;
+}
+
+// ── Reports / export buttons (demo — actual export is in the heavy analysis page) ─
+
+const EXPORT_FORMATS: Array<{ id: string; sub: string }> = [
+ { id: "PDF", sub: "сводный паспорт" },
+ { id: "CSV", sub: "таблица метрик" },
+ { id: "DOCX §22", sub: "пояснит. записка" },
+ { id: "PPTX", sub: "презентация" },
+];
+
+function ReportsContent({ onExport }: { onExport: (fmt: string) => void }) {
+ return (
+ <>
+
+
+ {EXPORT_FORMATS.map((f) => (
+
onExport(f.id)}
+ >
+
+
+
+
+
+ {f.id}
+ {f.sub}
+
+
+ ))}
+
+
+
+ Полная выгрузка отчётов доступна на странице глубокого анализа участка.
+
+ Источник: генератор отчётов ПТИЦА
+ >
+ );
+}
+
+// ── Compare (advisory — single-parcel cockpit; full сравнение в отдельном модуле) ─
+
+function CompareContent({ cad }: { cad: string }) {
+ return (
+ <>
+
+
+
+
+ Сравнение с участками-кандидатами доступно в модуле «Сравнение» Site
+ Finder — выберите несколько участков, чтобы сопоставить их по ёмкости,
+ рынку и риску.
+
+ Источник: реестр кандидатов Site Finder
+ >
+ );
+}
+
+// ── Registry ──────────────────────────────────────────────────────────────────
+
+export function getV2DrawerEntry(
+ key: V2DrawerKey,
+ analysis: ParcelAnalysis,
+ forecastReport: ForecastReport | undefined,
+ onExport: (fmt: string) => void,
+): V2DrawerEntry {
+ switch (key) {
+ case "parcel": {
+ const d = adaptPassportDrawer(analysis);
+ return {
+ eyebrow: "01 · ЕГРН",
+ title: "Паспорт участка",
+ sum: d.summary,
+ content: (
+ <>
+
+
+
+ {d.zouitOverlaps.length > 0 && (
+
+ {d.zouitCount} зон
+
+ }
+ >
+ {d.zouitOverlaps.map((z, i) => (
+
+ ))}
+
+ )}
+ Источник: ЕГРН · Росреестр · НСПД
+ >
+ ),
+ };
+ }
+ case "potential": {
+ const cap = adaptPotentialDrawer(analysis);
+ const build = adaptBuildabilityDrawer(analysis);
+ return {
+ eyebrow: "02 · ГРАДРЕГЛАМЕНТ",
+ title: "Потенциал застройки",
+ sum: cap.summary,
+ content: (
+ <>
+
+
+
+
+ {build.gauge.value == null ? "—" : `${build.gauge.value}%`} ·
+ предв.
+
+ }
+ >
+ {build.factors.map((f) => (
+
+ ))}
+
+ {build.summary}
+ Источник: ПЗЗ / НСПД · расчёт ПТИЦА
+ >
+ ),
+ };
+ }
+ case "product": {
+ const d = adaptProductDrawer(forecastReport);
+ return {
+ eyebrow: "03 · ПРОДУКТ",
+ title: "Рекомендованный продукт",
+ sum: d.summary,
+ content: (
+ <>
+
+
+
+ {d.available && d.mix.length > 0 && (
+
+ {d.mix.map((m, i) => (
+
+ ))}
+
+ )}
+ {d.usp.length > 0 && (
+
+ {d.usp.map((u, i) => (
+
+ ))}
+
+ )}
+
+ {d.available
+ ? "Класс и квартирография — рекомендация прогноза §22 (advisory)."
+ : "Продуктовая стратегия формируется в прогнозе §22 — откройте «Сценарии»."}
+
+ Источник: продуктовая модель ПТИЦА
+ >
+ ),
+ };
+ }
+ case "economics": {
+ const d = adaptEconomyDrawer();
+ return {
+ eyebrow: "04 · ФИНМОДЕЛЬ",
+ title: "Экономика проекта",
+ sum: d.summary,
+ content: (
+ <>
+
+
+
+ {d.summary}
+ Источник: финмодель ПТИЦА (предв.)
+ >
+ ),
+ };
+ }
+ case "risks": {
+ const d = adaptRisksDrawer(analysis);
+ return {
+ eyebrow: "05 · РИСКИ",
+ title: "Риски участка",
+ sum: d.summary,
+ content: (
+ <>
+
+ {d.gauge.value == null ? "—" : `${d.gauge.value}%`} ·{" "}
+ {d.gauge.label}
+
+ }
+ >
+ {d.breakdown.map((f) => (
+
+ ))}
+
+
+
+
+
+ {d.summary}
+ Источник: гейт-модель ПТИЦА (предв.)
+ >
+ ),
+ };
+ }
+ case "compare":
+ return {
+ eyebrow: "06 · СРАВНЕНИЕ",
+ title: "Сравнение участков",
+ sum: "Сопоставление участков-кандидатов по ключевым метрикам.",
+ content: ,
+ };
+ case "reports":
+ return {
+ eyebrow: "07 · ЭКСПОРТ",
+ title: "Отчёты и экспорт",
+ sum: "Выгрузка аналитики по участку.",
+ content: ,
+ };
+ case "restrictions": {
+ const d = adaptRestrictionsDrawer(analysis);
+ return {
+ eyebrow: "ОГРАНИЧЕНИЯ",
+ title: "Ограничения и ЗОУИТ",
+ sum: d.summary,
+ content: (
+ <>
+
+ {d.statusRows.map((r, i) => (
+
+ ))}
+
+ {d.zouitRows.length > 0 && (
+
+ {d.zouitRows.map((z, i) => (
+
+ ))}
+
+ )}
+ {d.summary}
+ Источник: ИСОГД · ЕГРН · НСПД
+ >
+ ),
+ };
+ }
+ case "engineering": {
+ const d = adaptEngineeringDrawer(analysis);
+ return {
+ eyebrow: "ИНЖЕНЕРИЯ",
+ title: "Инженерная обеспеченность",
+ sum: d.summary,
+ content: (
+ <>
+ {d.hasData ? (
+ <>
+
+ {d.rows.map((r, i) => (
+
+ ))}
+
+
+ {d.bars.map((b) => (
+
+ ))}
+
+ >
+ ) : (
+
+
+ Нет данных по инженерным сетям
+
+
+ )}
+ {d.summary}
+ Источник: ГКХ · сети РСО · НСПД
+ >
+ ),
+ };
+ }
+ case "market": {
+ const d = adaptMarketDrawer(analysis);
+ return {
+ eyebrow: "РЫНОК",
+ title: "Рынок района",
+ sum: d.summary,
+ content: (
+ <>
+
+
+
+
+
+ {d.competitors.length > 0 && (
+
+
+
+
+ ЖК
+ Класс
+ Кв.
+ Дист.
+
+
+
+ {d.competitors.map((c, i) => (
+
+ {c.name}
+ {c.cls}
+ {c.flats}
+ {c.distance}
+
+ ))}
+
+
+
+ )}
+ {d.summary}
+ Источник: рыночная аналитика ПТИЦА
+ >
+ ),
+ };
+ }
+ case "oks": {
+ const d = adaptOksDrawer();
+ return {
+ eyebrow: "ОКС",
+ title: "Объекты кап. строительства",
+ sum: d.summary,
+ content: (
+ <>
+
+
+ Нет данных реестра ОКС (ЕГРН)
+
+
+ {d.summary}
+ Источник: ЕГРН (ОКС)
+ >
+ ),
+ };
+ }
+ }
+}