From eb7290dcf0c1eedcc623a55dd30fa3dfd28d1b6e Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 21 Jun 2026 22:29:18 +0500 Subject: [PATCH] =?UTF-8?q?feat(site-finder):=20add=20Site=20Finder=20v2?= =?UTF-8?q?=20cockpit=20route=20(=D0=9F=D0=A2=D0=98=D0=A6=D0=90=20v2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the approved ptica-v2 prototype to a new non-destructive route /site-finder/analysis/{cad}/v2 — dark/light cockpit shell, real Leaflet map (reuses PticaMapInner), 6 scan cards, lower/bottom grids, 11 drawers and grounded chat. All values flow through the existing ptica-adapt layer (real /analyze data or honest «—»). One beta link added on the analysis page. --- .../analysis/[cad]/AnalysisPageContent.tsx | 15 +- .../analysis/[cad]/v2/V2PageContent.tsx | 70 + .../site-finder/analysis/[cad]/v2/page.tsx | 56 + .../analysis/[cad]/v2/v2.module.css | 1893 +++++++++++++++++ .../site-finder/ptica-v2/V2BottomGrid.tsx | 201 ++ .../site-finder/ptica-v2/V2Chat.tsx | 224 ++ .../site-finder/ptica-v2/V2Cockpit.tsx | 304 +++ .../site-finder/ptica-v2/V2Drawer.tsx | 49 + .../ptica-v2/V2DrawerPrimitives.tsx | 95 + .../site-finder/ptica-v2/V2Gauge.tsx | 72 + .../site-finder/ptica-v2/V2Icons.tsx | 53 + .../site-finder/ptica-v2/V2LowerGrid.tsx | 201 ++ .../components/site-finder/ptica-v2/V2Map.tsx | 57 + .../site-finder/ptica-v2/V2Passport.tsx | 90 + .../site-finder/ptica-v2/V2Rail.tsx | 132 ++ .../site-finder/ptica-v2/V2RightColumn.tsx | 146 ++ .../site-finder/ptica-v2/V2RiskCard.tsx | 62 + .../site-finder/ptica-v2/V2ScanCard.tsx | 116 + .../site-finder/ptica-v2/V2Topbar.tsx | 104 + .../ptica-v2/v2-drawer-registry.tsx | 465 ++++ 20 files changed, 4404 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/site-finder/analysis/[cad]/v2/V2PageContent.tsx create mode 100644 frontend/src/app/site-finder/analysis/[cad]/v2/page.tsx create mode 100644 frontend/src/app/site-finder/analysis/[cad]/v2/v2.module.css create mode 100644 frontend/src/components/site-finder/ptica-v2/V2BottomGrid.tsx create mode 100644 frontend/src/components/site-finder/ptica-v2/V2Chat.tsx create mode 100644 frontend/src/components/site-finder/ptica-v2/V2Cockpit.tsx create mode 100644 frontend/src/components/site-finder/ptica-v2/V2Drawer.tsx create mode 100644 frontend/src/components/site-finder/ptica-v2/V2DrawerPrimitives.tsx create mode 100644 frontend/src/components/site-finder/ptica-v2/V2Gauge.tsx create mode 100644 frontend/src/components/site-finder/ptica-v2/V2Icons.tsx create mode 100644 frontend/src/components/site-finder/ptica-v2/V2LowerGrid.tsx create mode 100644 frontend/src/components/site-finder/ptica-v2/V2Map.tsx create mode 100644 frontend/src/components/site-finder/ptica-v2/V2Passport.tsx create mode 100644 frontend/src/components/site-finder/ptica-v2/V2Rail.tsx create mode 100644 frontend/src/components/site-finder/ptica-v2/V2RightColumn.tsx create mode 100644 frontend/src/components/site-finder/ptica-v2/V2RiskCard.tsx create mode 100644 frontend/src/components/site-finder/ptica-v2/V2ScanCard.tsx create mode 100644 frontend/src/components/site-finder/ptica-v2/V2Topbar.tsx create mode 100644 frontend/src/components/site-finder/ptica-v2/v2-drawer-registry.tsx diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index 5bfed1e0..f18da37a 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -123,11 +123,24 @@ export function AnalysisPageContent({ cad }: Props) {
+ {/* Non-destructive entry into the new cockpit interface (Site Finder v2). */} + + Site Finder v2 (beta) → + . + * + * Loading / error states are themed in the scoped v2 module so they never leak + * cockpit styles to the rest of the app. + */ + +import Link from "next/link"; + +import styles from "./v2.module.css"; +import { + useParcelAnalyzeQuery, + useParcelForecastQuery, +} from "@/lib/site-finder-api"; +import type { ParcelAnalysis } from "@/types/site-finder"; +import { V2Cockpit } from "@/components/site-finder/ptica-v2/V2Cockpit"; + +interface Props { + cad: string; +} + +export function V2PageContent({ cad }: Props) { + // Horizon fixed at the default 12 mo — the cockpit is a single analysis view + // (the horizon selector lives on the heavy analysis / Scenarios page). + const { data, isLoading, error } = useParcelAnalyzeQuery(cad, 12); + const { data: forecastData } = useParcelForecastQuery(cad); + const forecastReport = + forecastData?.status === "ready" ? forecastData.report : undefined; + + if (isLoading) { + return ( +
+
+
+
Анализируем участок {cad}…
+
+
+
+ ); + } + + if (error || !data) { + const msg = + error instanceof Error ? error.message : "Ошибка загрузки анализа"; + return ( +
+
+
+
+
{msg}
+ + ← Вернуться к Site Finder + +
+
+
+
+ ); + } + + const analysis = data as unknown as ParcelAnalysis; + + return ; +} diff --git a/frontend/src/app/site-finder/analysis/[cad]/v2/page.tsx b/frontend/src/app/site-finder/analysis/[cad]/v2/page.tsx new file mode 100644 index 00000000..7c046a87 --- /dev/null +++ b/frontend/src/app/site-finder/analysis/[cad]/v2/page.tsx @@ -0,0 +1,56 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; + +import { V2PageContent } from "./V2PageContent"; + +// ── Metadata ────────────────────────────────────────────────────────────────── + +interface PageProps { + params: Promise<{ cad: string }>; +} + +// Next.js delivers `cad` URL-encoded (":" → "%3A"); decode once at the page +// boundary so downstream hooks/components see the canonical cad and re-encode +// exactly once when building API URLs (mirrors the sibling analysis page.tsx). +export async function generateMetadata({ + params, +}: PageProps): Promise { + const { cad: cadRaw } = await params; + const cad = decodeURIComponent(cadRaw); + return { + title: `ПТИЦА v2 · ${cad}`, + description: `Кокпит анализа участка ${cad} — Site Finder v2`, + }; +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default async function SiteFinderV2Page({ params }: PageProps) { + const { cad: cadRaw } = await params; + const cad = decodeURIComponent(cadRaw); + + return ( + + Загрузка кокпита… +
+ } + > + + + ); +} diff --git a/frontend/src/app/site-finder/analysis/[cad]/v2/v2.module.css b/frontend/src/app/site-finder/analysis/[cad]/v2/v2.module.css new file mode 100644 index 00000000..1de65eef --- /dev/null +++ b/frontend/src/app/site-finder/analysis/[cad]/v2/v2.module.css @@ -0,0 +1,1893 @@ +/* + * Site Finder v2 — «ПТИЦА» cockpit, ported 1:1 from the approved static + * prototype (ptica-v2/index.html). EVERY token and rule is scoped under + * `.v2Root[data-theme="dark"]` / `[data-theme="light"]` so the cockpit theme + * NEVER leaks into the rest of the (light) application — the wrapper carries the + * class AND the data-theme attribute. + * + * Fonts come from the globally-wired next/font CSS vars (--font-inter / + * --font-plex-mono — see app/layout.tsx); no new font deps. + */ + +/* ===================== SCOPED TOKENS ===================== */ +.v2Root { + --font-ui: + var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI", system-ui, + sans-serif; + --font-mono: + var(--font-plex-mono), "IBM Plex Mono", "Roboto Mono", ui-monospace, + monospace; + + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --line: 1px; + --line-strong: 1.5px; + + --accent-blue: #2f6f9f; + --accent-cyan: #7fd0ee; + --accent-cyan-strong: #8ed3ff; + --accent-green: #38c172; + --accent-green-soft: #2faa63; + --accent-yellow: #e0a93b; + --accent-red: #d2655b; + --accent-purple: #9b80d6; + --accent-orange: #e46c35; + + --res-electric: #e6b23c; + --res-water: #4d9bd7; + --res-sewer: #9b80d6; + --res-heat: #e46c35; + --res-gas: #d2655b; + --res-poi: #3fa77d; + + --gauge-track: 9px; +} + +.v2Root[data-theme="dark"] { + --bg: #060f16; + --bg-2: #08131c; + --grid-line: rgba(120, 165, 190, 0.06); + --surface: rgba(11, 26, 36, 0.72); + --surface-2: rgba(9, 22, 31, 0.62); + --surface-strong: rgba(12, 30, 42, 0.94); + --surface-muted: rgba(26, 52, 67, 0.55); + --surface-inset: rgba(4, 12, 18, 0.6); + --text: #dcebf2; + --text-strong: #f2fafe; + --text-muted: #8ba6b3; + --text-soft: #5f7886; + --border: rgba(150, 192, 214, 0.18); + --border-strong: rgba(150, 192, 214, 0.4); + --border-faint: rgba(150, 192, 214, 0.1); + --map-bg: #0a1820; + --glow: rgba(110, 200, 240, 0.28); + --glow-strong: rgba(110, 200, 240, 0.55); + --shadow: 0 18px 70px rgba(0, 0, 0, 0.5); + --verdict: var(--accent-green); + --gauge-good: var(--accent-green); + --chip-bg: rgba(20, 42, 56, 0.6); + --topbar-bg: rgba(7, 16, 23, 0.9); +} + +.v2Root[data-theme="light"] { + --bg: #eaeef2; + --bg-2: #e4e9ee; + --grid-line: rgba(70, 110, 135, 0.07); + --surface: #ffffff; + --surface-2: rgba(255, 255, 255, 0.85); + --surface-strong: #ffffff; + --surface-muted: rgba(222, 231, 238, 0.7); + --surface-inset: rgba(232, 238, 243, 0.8); + --text: #102132; + --text-strong: #06141f; + --text-muted: #51616f; + --text-soft: #768493; + --border: rgba(40, 70, 95, 0.2); + --border-strong: rgba(40, 70, 95, 0.36); + --border-faint: rgba(40, 70, 95, 0.1); + --map-bg: #d9e4ea; + --glow: rgba(70, 140, 180, 0.2); + --glow-strong: rgba(70, 140, 180, 0.4); + --shadow: + 0 1px 2px rgba(16, 24, 40, 0.06), 0 10px 28px rgba(40, 68, 92, 0.09); + --verdict: #1f9d57; + --gauge-good: #1f9d57; + --chip-bg: rgba(225, 234, 241, 0.85); + --topbar-bg: rgba(255, 255, 255, 0.92); +} + +/* ===================== ROOT / BASE ===================== */ +.v2Root { + position: relative; + width: 100%; + min-height: 100vh; + font-family: var(--font-ui); + background: var(--bg); + color: var(--text); + -webkit-font-smoothing: antialiased; + font-size: 12px; + line-height: 1.3; + font-variant-numeric: tabular-nums; +} +.v2Root :where(button) { + font: inherit; + cursor: pointer; + color: inherit; + border: none; + background: none; +} +.v2Root svg { + display: block; +} +.mono { + font-family: var(--font-mono); +} +.muted { + color: var(--text-muted); +} +.soft { + color: var(--text-soft); +} +.dash { + color: var(--text-soft); + font-family: var(--font-mono); +} + +.app { + width: 100%; + height: 100vh; + display: grid; + grid-template-rows: 56px 1fr 26px; + background: + linear-gradient(var(--grid-line) 1px, transparent 1px) 0 0 / 34px 34px, + linear-gradient(90deg, var(--grid-line) 1px, transparent 1px) 0 0 / 34px 34px, + radial-gradient(1200px 700px at 30% -10%, var(--bg-2), var(--bg)); + position: relative; + overflow: hidden; +} + +.lbl { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.13em; + text-transform: uppercase; + color: var(--text-muted); + font-weight: 500; +} +.sectionTitle { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--text-strong); + font-weight: 600; +} +.sectionSub { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.13em; + text-transform: uppercase; + color: var(--text-soft); + font-weight: 500; +} + +/* ===================== TOPBAR ===================== */ +.topbar { + grid-row: 1; + display: grid; + grid-template-columns: 300px 1fr 300px; + align-items: center; + border-bottom: 1px solid var(--border); + background: var(--topbar-bg); + padding: 0 18px; + backdrop-filter: blur(6px); + z-index: 10; +} +.brand { + display: flex; + align-items: center; + gap: 11px; + text-decoration: none; + color: inherit; +} +.brandLogo { + width: 30px; + height: 30px; + color: var(--accent-cyan); + flex: 0 0 auto; +} +.brandLogo svg { + width: 100%; + height: 100%; + filter: drop-shadow(0 0 6px var(--glow)); +} +.brandName { + font-family: var(--font-mono); + font-weight: 600; + font-size: 18px; + letter-spacing: 0.34em; + color: var(--text-strong); +} +.brandSub { + font-size: 7.5px; + letter-spacing: 0.05em; + color: var(--text-soft); + text-transform: uppercase; + margin-top: 2px; + line-height: 1.25; + max-width: 200px; +} +.tabs { + display: flex; + justify-content: center; + align-items: center; + gap: 30px; + height: 100%; +} +.tab { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-soft); + font-weight: 500; + position: relative; + height: 100%; + display: flex; + align-items: center; +} +.tabActive { + color: var(--text-strong); +} +.tabActive::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 2px; + background: var(--accent-cyan); + box-shadow: 0 0 8px var(--glow-strong); +} +.topbarRight { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; +} +.clock { + text-align: right; +} +.clock .time { + font-family: var(--font-mono); + font-size: 15px; + font-weight: 500; + color: var(--text-strong); + letter-spacing: 0.04em; +} +.clock .date { + font-family: var(--font-mono); + font-size: 8.5px; + color: var(--text-soft); + letter-spacing: 0.06em; + margin-top: 1px; +} +.ver { + font-family: var(--font-mono); + font-size: 8.5px; + letter-spacing: 0.08em; + color: var(--accent-cyan); + padding: 3px 7px; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + background: var(--surface-2); +} +.tbIcons { + display: flex; + gap: 6px; +} +.tbIc { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + color: var(--text-muted); + background: var(--surface-2); +} +.tbIc svg { + width: 13px; + height: 13px; +} + +/* ===================== BODY GRID ===================== */ +.body { + grid-row: 2; + display: grid; + grid-template-columns: 76px 1fr; + min-height: 0; +} + +/* rail */ +.rail { + display: flex; + flex-direction: column; + align-items: center; + border-right: 1px solid var(--border); + background: var(--surface-2); + padding: 10px 0; + gap: 2px; +} +.railItem { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 9px 0; + color: var(--text-soft); + position: relative; +} +.railItem svg { + width: 19px; + height: 19px; +} +.railItem .rlbl { + font-family: var(--font-mono); + font-size: 7px; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.railItemActive { + color: var(--accent-cyan); +} +.railItemActive::before { + content: ""; + position: absolute; + left: 0; + top: 6px; + bottom: 6px; + width: 2px; + background: var(--accent-cyan); + box-shadow: 0 0 8px var(--glow-strong); +} +.railSpacer { + flex: 1; +} + +/* content area */ +.content { + display: grid; + min-width: 0; + min-height: 0; + gap: 8px; + padding: 8px; + grid-template-rows: + minmax(0, 2.18fr) minmax(0, 1.18fr) minmax(0, 1.3fr) + minmax(0, 1fr); + overflow-y: auto; +} + +/* card primitive */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + position: relative; + overflow: hidden; +} +.cardH { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 11px 7px; + border-bottom: 1px solid var(--border-faint); + flex: 0 0 auto; +} +.cardH .ttl { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} +.cardB { + padding: 9px 11px; + flex: 1; + min-height: 0; +} +.more { + font-family: var(--font-mono); + font-size: 8.5px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--accent-cyan); + white-space: nowrap; + display: flex; + align-items: center; + gap: 3px; +} +.moreRow { + text-align: right; + margin-top: 6px; +} +.moreRowAuto { + text-align: right; + margin-top: auto; +} + +/* bracket corners (light only). bc1 = bottom-left, bc2 = bottom-right; the two + top corners are the ::before / ::after of the card itself. */ +.brk::before, +.brk::after, +.bc1, +.bc2 { + content: ""; + position: absolute; + width: 15px; + height: 15px; + pointer-events: none; + z-index: 3; +} +.v2Root[data-theme="light"] .brk::before { + top: -1px; + left: -1px; + border-top: 2px solid var(--border-strong); + border-left: 2px solid var(--border-strong); +} +.v2Root[data-theme="light"] .brk::after { + top: -1px; + right: -1px; + border-top: 2px solid var(--border-strong); + border-right: 2px solid var(--border-strong); +} +.v2Root[data-theme="light"] .brk .bc1 { + bottom: -1px; + left: -1px; + border-bottom: 2px solid var(--border-strong); + border-left: 2px solid var(--border-strong); +} +.v2Root[data-theme="light"] .brk .bc2 { + bottom: -1px; + right: -1px; + border-bottom: 2px solid var(--border-strong); + border-right: 2px solid var(--border-strong); +} + +/* rows */ +.row1 { + display: grid; + grid-template-columns: 1.74fr 1.06fr 0.86fr; + gap: 8px; + min-height: 0; +} +.row2 { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 8px; + min-height: 0; +} +.row3 { + display: grid; + grid-template-columns: 1fr 1fr 1.25fr 1fr; + gap: 8px; + min-height: 0; +} +.row4 { + display: grid; + grid-template-columns: 1.55fr 1.05fr 1.1fr; + gap: 8px; + min-height: 0; +} + +/* ===================== MAP ===================== */ +.mapCard { + padding: 0; +} +/* The Leaflet map fills the card; PticaMapInner brings its own internal + overlay/controls (reused plumbing). */ +.mapMount { + position: absolute; + inset: 0; + border-radius: var(--radius-sm); + overflow: hidden; +} +.mapLoading { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--map-bg); + color: var(--text-soft); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.mapHead { + position: absolute; + top: 9px; + left: 11px; + z-index: 5; + display: flex; + align-items: baseline; + gap: 8px; + pointer-events: none; +} +.mapHead .ttl { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-strong); + font-weight: 600; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); +} + +/* ===================== PASSPORT ===================== */ +.passport .cardB { + display: flex; + flex-direction: column; + gap: 0; + padding: 0; +} +.ppId { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 11px; + border-bottom: 1px solid var(--border-faint); +} +.ppId .v { + font-family: var(--font-mono); + font-size: 14px; + color: var(--text-strong); + letter-spacing: 0.02em; +} +.ppId .cp { + width: 14px; + height: 14px; + color: var(--text-soft); + margin-left: auto; +} +.ppGrid { + display: grid; + grid-template-columns: 1fr 1fr; +} +.ppCell { + padding: 9px 11px; + border-bottom: 1px solid var(--border-faint); +} +.ppCell:nth-child(odd) { + border-right: 1px solid var(--border-faint); +} +.ppCell .k { + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-soft); + margin-bottom: 4px; +} +.ppCell .vv { + font-size: 12px; + color: var(--text); + font-weight: 500; + line-height: 1.3; +} +.ppCell .vv.big { + font-family: var(--font-mono); + font-size: 13px; +} +.ppCellWide { + grid-column: span 2; + border-right: none; +} +.statusOn { + color: var(--accent-green); + font-family: var(--font-mono); + letter-spacing: 0.06em; + text-transform: uppercase; + font-size: 12px; +} + +/* ===================== RIGHT COLUMN row1 ===================== */ +.colStack { + display: grid; + grid-template-rows: 1.35fr 1fr; + gap: 8px; + min-height: 0; +} +.gaugeCard .cardB { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; +} +.gaugeWrap { + position: relative; + width: 118px; + height: 118px; +} +.gaugeWrap svg { + width: 100%; + height: 100%; + transform: rotate(-90deg); +} +.gaugeCenter { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} +.gaugeCenter .pct { + font-family: var(--font-mono); + font-size: 30px; + font-weight: 600; + line-height: 1; +} +.gaugeCenter .pct.mutedG { + color: var(--text-soft); +} +.gaugeCenter .cap { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.1em; + text-transform: uppercase; + margin-top: 3px; + text-align: center; +} +.gaugeFoot { + font-family: var(--font-mono); + font-size: 8px; + color: var(--text-soft); + letter-spacing: 0.06em; +} + +.invest .cardB { + display: flex; + flex-direction: column; + gap: 8px; +} +.invest .scoreRow { + display: flex; + align-items: center; + justify-content: space-between; +} +.invest .score { + font-family: var(--font-mono); + font-size: 26px; + font-weight: 600; + color: var(--text-strong); +} +.invest .score .u { + font-size: 13px; + color: var(--text-soft); +} +.invest .score.dashed { + color: var(--text-soft); +} +.invest .spark { + height: 26px; + color: var(--accent-cyan); + opacity: 0.8; +} +.invest .spark svg { + height: 100%; + width: auto; +} +.invest .prRow { + display: flex; + gap: 10px; +} +.invest .pr { + flex: 1; +} +.invest .pr .k { + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-soft); + margin-bottom: 3px; +} +.invest .pr .vv { + font-size: 13px; + font-weight: 600; + color: var(--accent-green); +} +.invest .pr .vv.amb { + color: var(--accent-yellow); +} +.invest .pr .vv.none { + color: var(--text-soft); + font-family: var(--font-mono); +} + +/* ===================== ROW2 dev scan ===================== */ +.kv { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + padding: 3.5px 0; +} +.kv .k { + font-family: var(--font-mono); + font-size: 8.5px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-soft); + white-space: nowrap; +} +.kv .v { + font-size: 11px; + color: var(--text); + font-weight: 500; + text-align: right; + font-variant-numeric: tabular-nums; +} +.kv .v.mono { + font-family: var(--font-mono); +} +.kv .v.dash { + color: var(--text-soft); + font-family: var(--font-mono); +} +.engRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding: 3px 0; + font-size: 9.5px; +} +.engRow .nm { + font-family: var(--font-mono); + font-size: 8.5px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text); + display: flex; + align-items: center; + gap: 6px; +} +.engRow .nm .sd { + width: 7px; + height: 7px; + border-radius: 50%; + flex: 0 0 auto; + box-shadow: 0 0 5px currentColor; +} +.engRow .st { + display: flex; + align-items: center; + gap: 6px; +} +.engRow .st .stt { + font-size: 9.5px; + font-weight: 500; +} +.engRow .st .dm { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-soft); + min-width: 46px; + text-align: right; +} +.ok { + color: var(--accent-green); +} +.warnc { + color: var(--accent-orange); +} +.badge { + font-family: var(--font-mono); + font-size: 7.5px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent-yellow); + border: 1px solid var(--accent-yellow); + border-radius: 3px; + padding: 1px 5px; + opacity: 0.85; +} +.badge.soon { + color: var(--accent-cyan); + border-color: var(--accent-cyan); +} +.note { + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.04em; + color: var(--text-soft); + margin-top: 3px; +} +.emptyState { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.04em; + color: var(--text-soft); + text-align: center; + padding: 10px 0; +} + +/* small risk gauge in row2 */ +.minigauge { + position: relative; + width: 74px; + height: 74px; + margin: 0 auto; +} +.minigauge svg { + width: 100%; + height: 100%; + transform: rotate(-90deg); +} +.minigauge .c { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} +.minigauge .c .p { + font-family: var(--font-mono); + font-size: 18px; + font-weight: 600; + color: var(--accent-cyan); +} +.riskCard .cardB { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; +} +.riskCard .rk { + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-soft); +} + +/* ===================== ROW3 ===================== */ +.oksPlan { + flex: 1; + display: flex; + gap: 9px; + min-height: 0; +} +.oksMini { + flex: 0 0 96px; + background: var(--surface-inset); + border: 1px solid var(--border-faint); + border-radius: var(--radius-xs); + position: relative; + overflow: hidden; +} +.oksMini svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} +.oksKv { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; +} +.dpGrid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 7px 10px; + flex: 1; + align-content: center; +} +.dpCell .k { + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--text-soft); + margin-bottom: 3px; +} +.dpCell .v { + font-family: var(--font-mono); + font-size: 15px; + font-weight: 600; + color: var(--text-strong); + white-space: nowrap; +} +.dpCell .v .u { + font-size: 10px; + color: var(--text-soft); +} +.dpCell .v.dash { + color: var(--text-soft); +} +.dpCellWide { + grid-column: span 3; + border-top: 1px solid var(--border-faint); + padding-top: 6px; + margin-top: 2px; +} + +/* product card */ +.chips { + display: flex; + gap: 5px; + margin-bottom: 9px; + flex-wrap: wrap; +} +.chip { + font-family: var(--font-mono); + font-size: 8.5px; + letter-spacing: 0.04em; + padding: 3px 8px; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + color: var(--text-soft); + background: var(--surface-2); +} +.chipActive { + color: var(--accent-cyan); + border-color: var(--accent-cyan); + background: var(--chip-bg); + box-shadow: 0 0 8px var(--glow); +} +.chipDim { + opacity: 0.45; +} +.aptrow { + display: flex; + align-items: center; + gap: 8px; + padding: 2.5px 0; +} +.aptrow .nm { + font-family: var(--font-mono); + font-size: 8.5px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-soft); + width: 34px; + flex: 0 0 auto; +} +.aptrow .track { + flex: 1; + height: 6px; + background: var(--surface-inset); + border-radius: 3px; + overflow: hidden; +} +.aptrow .track i { + display: block; + height: 100%; + background: var(--accent-cyan); + opacity: 0.85; + border-radius: 3px; +} +.aptrow .pc { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-soft); + width: 30px; + text-align: right; +} +.qgTitle { + font-family: var(--font-mono); + font-size: 7.5px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-soft); + margin-bottom: 5px; +} +.dimBars .track i { + background: var(--text-soft); + opacity: 0.3; +} + +/* insolation */ +.insoCard .cardB { + display: flex; + align-items: center; + gap: 12px; +} +.insoIso { + flex: 0 0 110px; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-soft); +} +.insoIso svg { + width: 108px; + height: 96px; +} +.insoTxt { + flex: 1; +} +.insoTxt .d { + font-size: 10px; + color: var(--text-soft); + margin-top: 6px; + line-height: 1.4; + max-width: 150px; +} + +/* ===================== ROW4 ===================== */ +.finCard .cardB { + display: flex; + align-items: stretch; + gap: 14px; +} +.finMain { + display: flex; + gap: 18px; + flex: 1; + align-items: center; + flex-wrap: wrap; +} +.finBig .k { + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-soft); + margin-bottom: 4px; +} +.finBig .v { + font-family: var(--font-mono); + font-size: 25px; + font-weight: 600; + color: var(--text-strong); + line-height: 1; +} +.finBig .v.dashed { + color: var(--text-soft); +} +.finBig .u { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-soft); + margin-top: 3px; + letter-spacing: 0.06em; +} +.finSep { + width: 1px; + background: var(--border-faint); + align-self: stretch; +} +.finRatios { + display: flex; + gap: 16px; + align-items: center; +} +.finRatios .r .k { + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-soft); + margin-bottom: 4px; +} +.finRatios .r .v { + font-family: var(--font-mono); + font-size: 18px; + font-weight: 600; + color: var(--text-strong); +} +.finRatios .r .v.dashed { + color: var(--text-soft); +} +.buyGauge { + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + border-left: 1px solid var(--border-faint); + padding-left: 14px; +} + +/* legal */ +.legalGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 14px; + flex: 1; + align-content: start; +} +.legalRow { + display: flex; + align-items: baseline; + justify-content: space-between; + padding: 4px 0; + border-bottom: 1px solid var(--border-faint); +} +.legalRow .k { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-soft); +} +.legalRow .v { + font-size: 11px; + color: var(--text); + font-weight: 500; +} +.legalRow .v.no { + color: var(--accent-green); +} +.legalRow .v.dash { + color: var(--text-soft); + font-family: var(--font-mono); +} + +/* verdict */ +.verdictCard .cardB { + display: flex; + align-items: center; + gap: 16px; +} +.verdictBig { + flex: 0 0 auto; + display: flex; + flex-direction: column; + justify-content: center; +} +.verdictBig .v { + font-family: var(--font-mono); + font-size: 34px; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--verdict); + line-height: 1; + text-shadow: 0 0 14px var(--glow); +} +.verdictBig .v.warn { + color: var(--accent-yellow); +} +.verdictBig .v.bad { + color: var(--accent-red); +} +.verdictBig .note { + margin-top: 8px; +} +.verdictCheck { + flex: 1; + display: flex; + flex-direction: column; + gap: 0; + min-width: 0; +} +.vchk { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + border-bottom: 1px solid var(--border-faint); + font-size: 10.5px; +} +.vchk svg { + width: 14px; + height: 14px; + flex: 0 0 auto; +} +.vchk.okRow { + color: var(--text); +} +.vchk.okRow svg { + color: var(--accent-green); +} +.vchk.warnRow svg { + color: var(--accent-orange); +} +.vchk.warnRow { + color: var(--text); +} +.vchk.badRow svg { + color: var(--accent-red); +} +.vchk.badRow { + color: var(--text); +} + +/* ===================== FOOTER ===================== */ +.footer { + grid-row: 3; + display: flex; + align-items: center; + justify-content: space-between; + border-top: 1px solid var(--border); + background: var(--topbar-bg); + padding: 0 18px; + font-family: var(--font-mono); + font-size: 8.5px; + letter-spacing: 0.06em; + color: var(--text-soft); + text-transform: uppercase; +} +.footer .left { + display: flex; + gap: 10px; + align-items: center; +} +.footer .left b { + color: var(--text-muted); + font-weight: 600; +} +.footDot { + color: var(--text-soft); + opacity: 0.6; +} +.footer .right { + color: var(--text-soft); +} + +/* ===================== TOAST ===================== */ +.toast { + position: fixed; + left: 50%; + bottom: 64px; + z-index: 90; + transform: translateX(-50%) translateY(14px); + background: var(--surface-strong); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 11px 18px; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.04em; + color: var(--text-strong); + box-shadow: var(--shadow); + opacity: 0; + visibility: hidden; + transition: + opacity 0.2s, + transform 0.2s, + visibility 0.2s; +} +.toastShow { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); +} + +/* theme toggle */ +.themeToggle { + position: fixed; + right: 14px; + bottom: 38px; + z-index: 50; + width: 38px; + height: 38px; + border-radius: 50%; + background: var(--surface-strong); + border: 1px solid var(--border-strong); + color: var(--accent-cyan); + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow); + font-size: 16px; +} + +/* ===================== STATE SCREENS ===================== */ +.stateScreen { + grid-row: 2; + display: flex; + align-items: center; + justify-content: center; + padding: 40px; +} +.stateBox { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.08em; + color: var(--text-muted); + text-transform: uppercase; +} +.stateError { + font-family: var(--font-mono); + font-size: 12px; + color: var(--accent-red); + margin-bottom: 12px; +} +.stateLink { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.06em; + color: var(--accent-cyan); + text-transform: uppercase; + text-decoration: none; +} + +/* ===================== OVERLAY (shared scrim) ===================== */ +.scrim { + position: fixed; + inset: 0; + z-index: 60; + background: rgba(2, 8, 12, 0.55); + opacity: 0; + visibility: hidden; + transition: + opacity 0.22s ease, + visibility 0.22s ease; + backdrop-filter: blur(1.5px); +} +.v2Root[data-theme="light"] .scrim { + background: rgba(30, 50, 70, 0.32); +} +.scrimOpen { + opacity: 1; + visibility: visible; +} + +/* ===================== DRAWER ===================== */ +.drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 70; + width: 480px; + max-width: 92vw; + background: var(--surface-strong); + border-left: 1px solid var(--border-strong); + box-shadow: -24px 0 70px rgba(0, 0, 0, 0.42); + transform: translateX(100%); + transition: transform 0.26s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + flex-direction: column; + background-image: + linear-gradient(var(--grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); + background-size: + 34px 34px, + 34px 34px; +} +.v2Root[data-theme="light"] .drawer { + box-shadow: -18px 0 50px rgba(40, 68, 92, 0.18); +} +.drawerOpen { + transform: translateX(0); +} +.drawerH { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: 16px 18px 13px; + border-bottom: 1px solid var(--border); + flex: 0 0 auto; + background: var(--topbar-bg); +} +.drawerH .dhLeft { + min-width: 0; +} +.drawerH .dhEyebrow { + font-family: var(--font-mono); + font-size: 8.5px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--accent-cyan); + margin-bottom: 5px; +} +.drawerH .dhTitle { + font-family: var(--font-mono); + font-size: 15px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-strong); + font-weight: 600; + line-height: 1.15; +} +.drawerH .dhSum { + font-size: 11px; + color: var(--text-muted); + margin-top: 7px; + line-height: 1.45; + max-width: 380px; +} +.drawerClose { + flex: 0 0 auto; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + background: var(--surface-2); + color: var(--text-muted); + font-size: 15px; + line-height: 1; +} +.drawerClose:hover { + color: var(--text-strong); + border-color: var(--border-strong); +} +.drawerB { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 16px 18px 22px; +} +.drawerB::-webkit-scrollbar { + width: 7px; +} +.drawerB::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 4px; +} + +/* drawer content primitives */ +.dsec { + margin-bottom: 18px; +} +.dsec:last-child { + margin-bottom: 0; +} +.dsecT { + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-soft); + margin-bottom: 9px; + display: flex; + align-items: center; + gap: 8px; +} +.dsecT::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border-faint); +} +.drow { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 14px; + padding: 7px 0; + border-bottom: 1px solid var(--border-faint); +} +.drow:last-child { + border-bottom: none; +} +.drow .k { + font-family: var(--font-mono); + font-size: 9.5px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-soft); + flex: 0 0 auto; + max-width: 48%; +} +.drow .v { + font-size: 12px; + color: var(--text); + font-weight: 500; + text-align: right; + font-variant-numeric: tabular-nums; + line-height: 1.35; +} +.drow .v.mono { + font-family: var(--font-mono); +} +.drow .v.dash { + color: var(--text-soft); + font-family: var(--font-mono); +} +.drow .v.ok { + color: var(--verdict); +} +.drow .v.warnc { + color: var(--accent-orange); +} +.dnote { + font-family: var(--font-mono); + font-size: 8.5px; + letter-spacing: 0.04em; + color: var(--text-soft); + margin-top: 10px; + line-height: 1.5; +} +.dsource { + font-family: var(--font-mono); + font-size: 8.5px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-soft); + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid var(--border-faint); + display: flex; + align-items: center; + gap: 7px; +} +.dsource svg { + width: 12px; + height: 12px; + flex: 0 0 auto; + opacity: 0.8; +} +.dbadge { + display: inline-block; + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 2px 7px; + border-radius: 3px; + border: 1px solid var(--accent-yellow); + color: var(--accent-yellow); +} +.dbadge.cyan { + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} +.dbadge.green { + border-color: var(--verdict); + color: var(--verdict); +} +.dbadgeRight { + margin-left: auto; +} + +/* drawer data table */ +.dtable { + width: 100%; + border-collapse: collapse; + font-variant-numeric: tabular-nums; +} +.dtable th, +.dtable td { + text-align: right; + padding: 6px 6px; + border-bottom: 1px solid var(--border-faint); + white-space: nowrap; +} +.dtable th { + font-family: var(--font-mono); + font-size: 7.5px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-soft); + font-weight: 500; +} +.dtable td { + font-size: 10px; + color: var(--text); +} +.dtable th:first-child, +.dtable td:first-child { + text-align: left; +} +.dtable td.mono { + font-family: var(--font-mono); +} +.dtable tr.me td { + color: var(--accent-cyan); +} +.dtable td.dash { + color: var(--text-soft); + font-family: var(--font-mono); +} + +/* factor bars */ +.dfac { + margin: 9px 0; +} +.dfacH { + display: flex; + justify-content: space-between; + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-soft); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 4px; +} +.dfacH b { + color: var(--text); + font-weight: 500; +} +.dfacTrack { + height: 7px; + background: var(--surface-inset); + border-radius: 4px; + overflow: hidden; +} +.dfacTrack i { + display: block; + height: 100%; + border-radius: 4px; +} + +/* drawer export buttons */ +.dexport { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 9px; +} +.dbtn { + display: flex; + align-items: center; + gap: 9px; + padding: 11px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + background: var(--surface-2); + color: var(--text); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.04em; + text-transform: uppercase; + text-align: left; +} +.dbtn:hover { + border-color: var(--accent-cyan); + color: var(--accent-cyan); + box-shadow: 0 0 10px var(--glow); +} +.dbtn svg { + width: 15px; + height: 15px; + flex: 0 0 auto; + opacity: 0.85; +} +.dbtn .ds { + font-size: 7.5px; + color: var(--text-soft); + text-transform: none; + letter-spacing: 0; +} +.dbtn b { + font-weight: 600; + display: block; +} + +/* ===================== CHAT ===================== */ +.chatFab { + position: fixed; + right: 14px; + bottom: 84px; + z-index: 55; + width: 46px; + height: 46px; + border-radius: 50%; + background: var(--accent-blue); + border: 1px solid var(--accent-cyan); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + box-shadow: + 0 8px 28px rgba(0, 0, 0, 0.4), + 0 0 14px var(--glow-strong); +} +.chatFab svg { + width: 21px; + height: 21px; +} +.chatPanel { + position: fixed; + right: 0; + top: 0; + bottom: 0; + z-index: 75; + width: 380px; + max-width: 90vw; + background: var(--surface-strong); + border-left: 1px solid var(--border-strong); + box-shadow: -24px 0 70px rgba(0, 0, 0, 0.42); + transform: translateX(110%); + transition: transform 0.26s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + flex-direction: column; +} +.v2Root[data-theme="light"] .chatPanel { + box-shadow: -18px 0 50px rgba(40, 68, 92, 0.18); +} +.chatPanelOpen { + transform: translateX(0); +} +.chatH { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 14px 16px 12px; + border-bottom: 1px solid var(--border); + background: var(--topbar-bg); + flex: 0 0 auto; +} +.chatH .chL { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; +} +.chatH .chIc { + width: 26px; + height: 26px; + border-radius: 50%; + background: var(--accent-blue); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + flex: 0 0 auto; +} +.chatH .chIc svg { + width: 14px; + height: 14px; +} +.chatH .chT { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-strong); + font-weight: 600; +} +.chatH .chS { + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.06em; + color: var(--text-soft); + margin-top: 2px; +} +.chatMsgs { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} +.chatMsgs::-webkit-scrollbar { + width: 7px; +} +.chatMsgs::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 4px; +} +.msg { + max-width: 84%; + font-size: 12px; + line-height: 1.5; + padding: 10px 12px; + border-radius: 12px; + white-space: pre-wrap; +} +.msgBot { + align-self: flex-start; + background: var(--surface-muted); + border: 1px solid var(--border-faint); + color: var(--text); + border-top-left-radius: 3px; +} +.msgUser { + align-self: flex-end; + background: var(--accent-blue); + color: #fff; + border-top-right-radius: 3px; +} +.chatChips { + display: flex; + flex-wrap: wrap; + gap: 7px; + padding: 0 16px 12px; + flex: 0 0 auto; +} +.chatChip { + font-family: var(--font-mono); + font-size: 9.5px; + letter-spacing: 0.02em; + padding: 6px 11px; + border: 1px solid var(--border); + border-radius: 14px; + color: var(--text-muted); + background: var(--surface-2); +} +.chatChip:hover { + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} +.chatInput { + display: flex; + gap: 8px; + padding: 12px 14px; + border-top: 1px solid var(--border); + background: var(--topbar-bg); + flex: 0 0 auto; +} +.chatInput input { + flex: 1; + background: var(--surface-inset); + border: 1px solid var(--border); + border-radius: var(--radius-xs); + color: var(--text); + font-family: var(--font-ui); + font-size: 12px; + padding: 9px 11px; + outline: none; +} +.chatInput input:focus { + border-color: var(--accent-cyan); +} +.chatInput input::placeholder { + color: var(--text-soft); +} +.chatSend { + flex: 0 0 auto; + width: 38px; + border: 1px solid var(--accent-cyan); + border-radius: var(--radius-xs); + background: var(--accent-blue); + color: #fff; + display: flex; + align-items: center; + justify-content: center; +} +.chatSend svg { + width: 16px; + height: 16px; +} +.chatTyping { + align-self: flex-start; + display: flex; + gap: 4px; + padding: 11px 13px; + background: var(--surface-muted); + border: 1px solid var(--border-faint); + border-radius: 12px; + border-top-left-radius: 3px; +} +.chatTyping i { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--text-soft); + animation: tdot 1s infinite; +} +.chatTyping i:nth-child(2) { + animation-delay: 0.2s; +} +.chatTyping i:nth-child(3) { + animation-delay: 0.4s; +} +@keyframes tdot { + 0%, + 60%, + 100% { + opacity: 0.3; + transform: translateY(0); + } + 30% { + opacity: 1; + transform: translateY(-3px); + } +} diff --git a/frontend/src/components/site-finder/ptica-v2/V2BottomGrid.tsx b/frontend/src/components/site-finder/ptica-v2/V2BottomGrid.tsx new file mode 100644 index 00000000..71694f53 --- /dev/null +++ b/frontend/src/components/site-finder/ptica-v2/V2BottomGrid.tsx @@ -0,0 +1,201 @@ +"use client"; + +/** + * V2BottomGrid — row 4 (prototype `.row4`): Investment Clearance (финмодель) · + * Legal Status · Site Verdict. Финмодель bignums are honest «—» (после финмодели + * §23); the buy-signal gauge reads the §22 deficit_index when ready. Legal Status + * surfaces REAL ЗОУИТ / красные линии / gate, «—» for the rest. Site Verdict is + * the JTBD plaque + checklist derived from the gate verdict + real signals. + */ + +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 { + adaptInvestmentClearance, + adaptBuySignalGauge, + adaptLegalStatus, + adaptSiteVerdict, +} from "@/components/site-finder/ptica/ptica-adapt"; +import type { + PticaSiteVerdict, + PticaVerdictCheckItem, +} from "@/components/site-finder/ptica/ptica-adapt"; +import { V2Gauge } from "@/components/site-finder/ptica-v2/V2Gauge"; +import { V2CheckIcon, V2WarnIcon } from "@/components/site-finder/ptica-v2/V2Icons"; +import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry"; + +const VERDICT_CLASS: Record = { + good: "", + warn: styles.warn, + bad: styles.bad, +}; + +function CheckRow({ item }: { item: PticaVerdictCheckItem }) { + const cls = + item.tone === "ok" + ? styles.okRow + : item.tone === "warn" + ? styles.warnRow + : styles.badRow; + return ( +
+ {item.tone === "ok" ? : } + {item.text} +
+ ); +} + +interface Props { + analysis: ParcelAnalysis; + forecastReport?: ForecastReport; + onOpenDrawer: (key: V2DrawerKey) => void; +} + +export function V2BottomGrid({ analysis, forecastReport, onOpenDrawer }: Props) { + const clearance = adaptInvestmentClearance(); + const buy = adaptBuySignalGauge(forecastReport); + const legal = adaptLegalStatus(analysis); + const verdict = adaptSiteVerdict(analysis, forecastReport); + + const buyColor = + buy.gauge.value == null + ? "var(--text-soft)" + : buy.gauge.tone === "good" + ? "var(--accent-green)" + : buy.gauge.tone === "bad" + ? "var(--accent-red)" + : "var(--accent-yellow)"; + + return ( +
+ {/* Investment Clearance */} +
+ + +
+
+ Investment Clearance + финансовая модель +
+ +
+
+
+ {clearance.bignums + .filter((b) => b.unit) + .map((b) => ( +
+
{b.label}
+
+ {b.value} +
+
{b.unit}
+
+ ))} +
+
+ {clearance.bignums + .filter((b) => !b.unit) + .map((b) => ( +
+
{b.label}
+
+ {b.value} +
+
+ ))} +
+
+
+
+ Buy signal +
+
+ +
+ + {buy.gauge.value == null ? "—" : `${buy.gauge.value}%`} + +
+
+
+ {buy.word} +
+
+
+
+ + {/* Legal Status */} +
+
+
+ Legal Status +
+
+
+
+ {legal.rows.map((r) => ( +
+ {r.k} + + {r.field.value} + +
+ ))} +
+
+ +
+
+
+ + {/* Site Verdict */} +
+ + +
+
+ Site Verdict +
+
+
+
+
+ {verdict.word} +
+
+
+ {verdict.checklist.map((item, i) => ( + + ))} +
+
+
+
+ ); +} diff --git a/frontend/src/components/site-finder/ptica-v2/V2Chat.tsx b/frontend/src/components/site-finder/ptica-v2/V2Chat.tsx new file mode 100644 index 00000000..e804fb87 --- /dev/null +++ b/frontend/src/components/site-finder/ptica-v2/V2Chat.tsx @@ -0,0 +1,224 @@ +"use client"; + +/** + * V2Chat — floating chat panel (prototype `.chat-panel`). Grounded, local + * keyword answerer over the SAME ptica-adapt view-models the cockpit renders, so + * the answers never invent numbers: площадь/регламент/рынок/инженерия/риски are + * read straight from the adapters (REAL or honest «—»). No HTTP — this mirrors + * the prototype's grounded chat; the full LLM chat lives on the heavy analysis + * page. + */ + +import { useMemo, useState } 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 { + adaptPassport, + adaptUrbanCard, + adaptMarketCard, + adaptEngineeringCard, + adaptRiskGauge, + adaptBuildabilityGauge, + adaptInvestScore, +} from "@/components/site-finder/ptica/ptica-adapt"; + +interface ChatMsg { + text: string; + who: "bot" | "user"; +} + +const CHIPS = [ + "Какая площадь?", + "Можно ли строить?", + "Какая цена в районе?", + "Какие риски?", +]; + +function joinReal(label: string, value: string, isReal: boolean): string { + return isReal ? `${label}: ${value}.` : `${label}: данных пока нет (—).`; +} + +interface Props { + analysis: ParcelAnalysis; + forecastReport?: ForecastReport; + open: boolean; + onClose: () => void; +} + +export function V2Chat({ analysis, forecastReport, open, onClose }: Props) { + const passport = useMemo(() => adaptPassport(analysis), [analysis]); + const urban = useMemo(() => adaptUrbanCard(analysis), [analysis]); + const market = useMemo(() => adaptMarketCard(analysis), [analysis]); + const engineering = useMemo(() => adaptEngineeringCard(analysis), [analysis]); + const risk = useMemo(() => adaptRiskGauge(analysis), [analysis]); + const build = useMemo(() => adaptBuildabilityGauge(analysis), [analysis]); + const invest = useMemo( + () => adaptInvestScore(analysis, forecastReport), + [analysis, forecastReport], + ); + + const answer = useMemo(() => { + return (q: string): string => { + const t = q.toLowerCase(); + const has = (...ws: string[]) => ws.some((w) => t.includes(w)); + + if (has("площад", "гектар", "га")) { + return joinReal("Площадь участка", passport.area.value, passport.area.isReal); + } + if (has("строит", "застрой", "ксит", "высот", "плотн", "регламент", "зона")) { + const zone = urban.rows.find((r) => r.key === "Зона")?.field; + const far = urban.rows.find((r) => r.key === "Плотность (КСИТ)")?.field; + const height = urban.rows.find((r) => r.key === "Пред. высота")?.field; + const buildPct = build.value == null ? "—" : `${build.value}%`; + return ( + `${joinReal("Зона", zone?.value ?? "—", zone?.isReal ?? false)} ` + + `${joinReal("Плотность (КСИТ)", far?.value ?? "—", far?.isReal ?? false)} ` + + `${joinReal("Предельная высота", height?.value ?? "—", height?.isReal ?? false)} ` + + `Buildability ${buildPct} (${build.footnote ?? "предв."}).` + ); + } + if (has("цен", "медиан", "рынок", "стоим", "спрос")) { + const median = market.rows.find((r) => r.key === "Медиана цены")?.field; + const demand = market.rows.find((r) => r.key === "Уровень спроса")?.field; + const comp = market.rows.find((r) => r.key === "Конкурентов")?.field; + return ( + `${joinReal("Медиана цены в районе", median?.value ?? "—", median?.isReal ?? false)} ` + + `${joinReal("Уровень спроса", demand?.value ?? "—", demand?.isReal ?? false)} ` + + `${joinReal("Конкурентов", comp?.value ?? "—", comp?.isReal ?? false)}` + ); + } + if (has("риск")) { + const pct = risk.value == null ? "—" : `${risk.value}%`; + return `Сводный риск ${pct} — ${risk.label} (${risk.footnote ?? "предв."}).`; + } + if (has("инженер", "сет", "подключ", "тепло", "вод", "электр", "газ", "канализ")) { + if (engineering.rows.length === 0) { + return "Данных по инженерным сетям пока нет (—)."; + } + const parts = engineering.rows.map( + (r) => `${r.key} ${r.field.value}`, + ); + return `Расстояния до точек подключения: ${parts.join(", ")}.`; + } + if (has("инвест", "скор", "оценк", "потенциал")) { + return ( + `${joinReal("Инвест-скор", invest.score.value, invest.score.isReal)} ` + + `${joinReal("Сигнал покупки", invest.buySignal.value, invest.buySignal.isReal)}` + ); + } + return ( + "Я отвечаю по данным анализа участка: площадь, градрегламент, рынок, " + + "инженерия, риски и инвест-скор. Где данных нет — отвечаю честным «—»." + ); + }; + }, [passport, urban, market, engineering, risk, build, invest]); + + const [msgs, setMsgs] = useState([ + { + who: "bot", + text: `Здравствуйте! Я помощник по участку ${analysis.cad_num}. Спросите про площадь, регламент, рынок, инженерию или риски.`, + }, + ]); + const [typing, setTyping] = useState(false); + const [input, setInput] = useState(""); + + function send(q: string) { + const question = q.trim(); + if (!question) return; + setMsgs((m) => [...m, { who: "user", text: question }]); + setInput(""); + setTyping(true); + window.setTimeout(() => { + setTyping(false); + setMsgs((m) => [...m, { who: "bot", text: answer(question) }]); + }, 420); + } + + return ( + + ); +} diff --git a/frontend/src/components/site-finder/ptica-v2/V2Cockpit.tsx b/frontend/src/components/site-finder/ptica-v2/V2Cockpit.tsx new file mode 100644 index 00000000..819e80fa --- /dev/null +++ b/frontend/src/components/site-finder/ptica-v2/V2Cockpit.tsx @@ -0,0 +1,304 @@ +"use client"; + +/** + * V2Cockpit — the composed «ПТИЦА» cockpit (prototype #app). Owns: + * - theme (dark/light) with a floating ☾/☀ toggle + localStorage persistence + * (SSR-guarded), scoped via `data-theme` on `.v2Root`, + * - the open-drawer key (rail + card "ПОДРОБНЕЕ" triggers), deep-linked via + * `?drawer=` so drawers are linkable / back-button friendly, + * - the chat panel open-state + a shared scrim backing both drawer and chat, + * - the 4-row cockpit grid (map+passport+gauges / 6 scan cards / lower / bottom). + * + * All rendered values flow through the ptica-adapt layer — REAL where /analyze + * provides it, honest «—» otherwise. No fabricated numbers. + */ + +import { useCallback, useEffect, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; + +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 { + adaptUrbanCard, + adaptRestrictionsCard, + adaptEngineeringCard, + adaptMarketCard, + adaptEconomyCard, + adaptEnvironmentCard, +} from "@/components/site-finder/ptica/ptica-adapt"; +import { V2Topbar } from "@/components/site-finder/ptica-v2/V2Topbar"; +import { V2Rail } from "@/components/site-finder/ptica-v2/V2Rail"; +import { V2Map } from "@/components/site-finder/ptica-v2/V2Map"; +import { V2Passport } from "@/components/site-finder/ptica-v2/V2Passport"; +import { V2RightColumn } from "@/components/site-finder/ptica-v2/V2RightColumn"; +import { V2ScanCard } from "@/components/site-finder/ptica-v2/V2ScanCard"; +import { V2RiskCard } from "@/components/site-finder/ptica-v2/V2RiskCard"; +import { V2LowerGrid } from "@/components/site-finder/ptica-v2/V2LowerGrid"; +import { V2BottomGrid } from "@/components/site-finder/ptica-v2/V2BottomGrid"; +import { V2Drawer } from "@/components/site-finder/ptica-v2/V2Drawer"; +import { V2Chat } from "@/components/site-finder/ptica-v2/V2Chat"; +import { + asV2DrawerKey, + getV2DrawerEntry, + type V2DrawerKey, +} from "@/components/site-finder/ptica-v2/v2-drawer-registry"; + +type Theme = "dark" | "light"; +const THEME_KEY = "ptica-v2-theme"; + +interface Props { + analysis: ParcelAnalysis; + forecastReport?: ForecastReport; +} + +export function V2Cockpit({ analysis, forecastReport }: Props) { + // ── Theme (scoped, localStorage-persisted, SSR-guarded) ───────────────────── + const [theme, setTheme] = useState("dark"); + useEffect(() => { + if (typeof window === "undefined") return; + try { + const saved = window.localStorage.getItem(THEME_KEY); + if (saved === "light" || saved === "dark") setTheme(saved); + } catch { + // ignore storage errors + } + }, []); + const toggleTheme = useCallback(() => { + setTheme((prev) => { + const next: Theme = prev === "dark" ? "light" : "dark"; + try { + window.localStorage.setItem(THEME_KEY, next); + } catch { + // ignore storage errors + } + return next; + }); + }, []); + + // ── Drawer open-state + deep-link (?drawer=) ─────────────────────────── + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [openDrawer, setOpenDrawer] = useState(null); + const [chatOpen, setChatOpen] = useState(false); + const [toast, setToast] = useState(null); + + const drawerParam = searchParams.get("drawer"); + useEffect(() => { + setOpenDrawer(asV2DrawerKey(drawerParam)); + }, [drawerParam]); + + const syncUrl = useCallback( + (key: V2DrawerKey | null) => { + const params = new URLSearchParams(searchParams.toString()); + if (key) params.set("drawer", key); + else params.delete("drawer"); + const qs = params.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + }, + [router, pathname, searchParams], + ); + + const handleOpenDrawer = useCallback( + (key: V2DrawerKey) => { + setChatOpen(false); + setOpenDrawer(key); + syncUrl(key); + }, + [syncUrl], + ); + const handleCloseDrawer = useCallback(() => { + setOpenDrawer(null); + syncUrl(null); + }, [syncUrl]); + + const openChat = useCallback(() => { + setOpenDrawer(null); + syncUrl(null); + setChatOpen(true); + }, [syncUrl]); + const closeChat = useCallback(() => setChatOpen(false), []); + + // ESC closes drawer + chat + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") { + setOpenDrawer(null); + setChatOpen(false); + syncUrl(null); + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [syncUrl]); + + // Toast auto-dismiss + useEffect(() => { + if (!toast) return; + const id = window.setTimeout(() => setToast(null), 2400); + return () => window.clearTimeout(id); + }, [toast]); + + const handleExport = useCallback((fmt: string) => { + setToast(`Экспорт «${fmt}» доступен на странице глубокого анализа`); + }, []); + + const drawerEntry = openDrawer + ? getV2DrawerEntry(openDrawer, analysis, forecastReport, handleExport) + : null; + + const scrimOpen = Boolean(openDrawer) || chatOpen; + + // ── Scan-card view-models (ptica-adapt) ───────────────────────────────────── + const urban = adaptUrbanCard(analysis); + const restrictions = adaptRestrictionsCard(analysis); + const engineering = adaptEngineeringCard(analysis); + const market = adaptMarketCard(analysis); + const economy = adaptEconomyCard(); + const environment = adaptEnvironmentCard(analysis); + + return ( +
+
+ + +
+ + +
+ {/* 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 */} + + + {/* 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 ( + + ); +} 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 ( + + ); +} 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} + +
+ ))} +
+
+
+ +
+
+
+ + {/* Development Potential */} +
+
+
+ Development Potential +
+
+
+
+ {potential.metrics.map((m) => ( +
+
{m.label}
+
+ {m.value} + {m.unit && {m.unit}} +
+
+ ))} +
+
+ +
+
+
+ + {/* Recommended Product */} +
+
+
+ Recommended Product + продуктовая стратегия +
+
+
+
+ Класс + + {product.objClass.value} + +
+ {product.available && product.mix.length > 0 ? ( + <> +
Квартирография
+
+ {product.mix.map((m) => ( +
+ {m.bucket} + + + + {m.pct}% +
+ ))} +
+ + ) : ( +
после прогноза (Сценарии)
+ )} +
+ +
+
+
+ + {/* 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 ( +
+
+
+ Паспорт участка +
+ +
+
+
+ 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 ( + + ); +} 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 */} +
+
+
+ Инвест-скор +
+ +
+
+
+
+ {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 ( +
+
+
+ Риски +
+
+
+
Сводный риск
+
+ +
+ + {pct} + +
+
+
+ {gauge.label} +
+ +
+
+ ); +} 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 ( +
+ + + + + {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 ? ( + + ) : ( + + ), + ) + )} +
+ +
+
+
+ ); +} 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 ( +
+ + +
+
ПТИЦА
+
+ платформа территориального +
+ информационно-цифрового анализа +
+
+ + + + +
+
+
+ {now?.time ?? "—"} +
+
+ {now?.date ?? "—"} +
+
+
SITE FINDER v2
+
+ + + + + + +
+
+
+ ); +} 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) => ( + + ))} +
+
+ + Полная выгрузка отчётов доступна на странице глубокого анализа участка. + + Источник: генератор отчётов ПТИЦА + + ); +} + +// ── 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} + Источник: ЕГРН (ОКС) + + ), + }; + } + } +} -- 2.45.3