feat(ptica): detail drawer system + 17 drawers [PR#4] #1834
13 changed files with 2837 additions and 29 deletions
|
|
@ -1,20 +1,27 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* PticaPageContent — client root of the ПТИЦА cockpit (INCREMENT 1).
|
||||
* PticaPageContent — client root of the ПТИЦА cockpit.
|
||||
*
|
||||
* Wires the SAME /analyze data as the existing light analysis page via
|
||||
* useParcelAnalyzeQuery, narrows the response to ParcelAnalysis (the sanctioned
|
||||
* `as unknown as` pattern — see AnalysisPageContent.tsx:111), and composes the
|
||||
* dark operator-terminal shell. Only the 'analysis' tab renders content in PR#1;
|
||||
* Scenarios / Reports / Compare show an honest "В разработке" panel.
|
||||
* dark operator-terminal shell.
|
||||
*
|
||||
* PR#4 — DETAIL DRAWER system: this root owns the open-drawer state
|
||||
* (`openDrawer: DrawerKey | null`), threads an `onOpenDrawer(key)` callback down
|
||||
* the tree (hero cards + scan cards), and renders the matching drawer content
|
||||
* inside a single controlled <PticaDrawer>. Deep-link: `?drawer=<key>` opens a
|
||||
* drawer on mount; opening/closing updates the URL query via `router.replace`
|
||||
* (shallow, no full navigation) so drawers are linkable / back-button friendly.
|
||||
*
|
||||
* The root carries BOTH `.pticaRoot` and `data-theme="dark"` so the scoped dark
|
||||
* tokens apply here and never leak to the light pages.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import styles from "./ptica.module.css";
|
||||
import {
|
||||
|
|
@ -29,6 +36,12 @@ import { DevelopmentScan } from "@/components/site-finder/ptica/DevelopmentScan"
|
|||
import { PticaScenarios } from "@/components/site-finder/ptica/scenarios/PticaScenarios";
|
||||
import { PticaReports } from "@/components/site-finder/ptica/reports/PticaReports";
|
||||
import { PticaCompare } from "@/components/site-finder/ptica/compare/PticaCompare";
|
||||
import { PticaDrawer } from "@/components/site-finder/ptica/drawers/PticaDrawer";
|
||||
import { getDrawerEntry } from "@/components/site-finder/ptica/drawers/drawer-registry";
|
||||
import {
|
||||
asDrawerKey,
|
||||
type DrawerKey,
|
||||
} from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||
|
||||
interface Props {
|
||||
cad: string;
|
||||
|
|
@ -45,6 +58,43 @@ export function PticaPageContent({ cad }: Props) {
|
|||
const forecastReport =
|
||||
forecastData?.status === "ready" ? forecastData.report : undefined;
|
||||
|
||||
// ── Drawer open-state + deep-link (?drawer=<key>) ──────────────────────────
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [openDrawer, setOpenDrawer] = useState<DrawerKey | null>(null);
|
||||
|
||||
// On mount / URL change, sync the open drawer from the `?drawer=` query.
|
||||
const drawerParam = searchParams.get("drawer");
|
||||
useEffect(() => {
|
||||
setOpenDrawer(asDrawerKey(drawerParam));
|
||||
}, [drawerParam]);
|
||||
|
||||
// Update the URL query shallowly (no full navigation) when opening/closing.
|
||||
const syncUrl = useCallback(
|
||||
(key: DrawerKey | 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: DrawerKey) => {
|
||||
setOpenDrawer(key);
|
||||
syncUrl(key);
|
||||
},
|
||||
[syncUrl],
|
||||
);
|
||||
|
||||
const handleCloseDrawer = useCallback(() => {
|
||||
setOpenDrawer(null);
|
||||
syncUrl(null);
|
||||
}, [syncUrl]);
|
||||
|
||||
// ── Loading ────────────────────────────────────────────────────────────────
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -78,13 +128,24 @@ export function PticaPageContent({ cad }: Props) {
|
|||
// the same /analyze endpoint. Sanctioned pattern (AnalysisPageContent.tsx:111).
|
||||
const analysis = data as unknown as ParcelAnalysis;
|
||||
|
||||
const drawerEntry = openDrawer
|
||||
? getDrawerEntry(openDrawer, analysis, forecastReport)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={styles.pticaRoot} data-theme="dark">
|
||||
<PticaShell activeTab={tab} onTabChange={setTab}>
|
||||
{tab === "analysis" && (
|
||||
<>
|
||||
<PticaHero analysis={analysis} forecastReport={forecastReport} />
|
||||
<DevelopmentScan analysis={analysis} />
|
||||
<PticaHero
|
||||
analysis={analysis}
|
||||
forecastReport={forecastReport}
|
||||
onOpenDrawer={handleOpenDrawer}
|
||||
/>
|
||||
<DevelopmentScan
|
||||
analysis={analysis}
|
||||
onOpenDrawer={handleOpenDrawer}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{tab === "scenarios" && (
|
||||
|
|
@ -97,6 +158,15 @@ export function PticaPageContent({ cad }: Props) {
|
|||
{tab === "reports" && <PticaReports cad={cad} analysis={analysis} />}
|
||||
{tab === "compare" && <PticaCompare cad={cad} />}
|
||||
</PticaShell>
|
||||
|
||||
<PticaDrawer
|
||||
open={Boolean(drawerEntry)}
|
||||
title={drawerEntry?.title ?? ""}
|
||||
sub={drawerEntry?.sub}
|
||||
onClose={handleCloseDrawer}
|
||||
>
|
||||
{drawerEntry?.content}
|
||||
</PticaDrawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1394,6 +1394,254 @@
|
|||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ===================== DETAIL DRAWER (PR#4) ===================== */
|
||||
.drawerScrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(2, 8, 12, 0.62);
|
||||
backdrop-filter: blur(2px);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
visibility 0.18s ease;
|
||||
z-index: 60;
|
||||
}
|
||||
.drawerScrimOpen {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100vh;
|
||||
width: 480px;
|
||||
max-width: 100vw;
|
||||
background: var(--surface-strong);
|
||||
border-left: var(--line) solid var(--border-strong);
|
||||
box-shadow: var(--shadow);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.18s ease;
|
||||
z-index: 61;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.drawerOpen {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.drawerHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 18px;
|
||||
border-bottom: var(--line) solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.drawerTitle {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.drawerSub {
|
||||
margin-top: 3px;
|
||||
font-size: 10px;
|
||||
color: var(--text-soft);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.drawerClose {
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: var(--line) solid var(--border);
|
||||
border-radius: var(--radius-xs);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.drawerClose:hover {
|
||||
color: var(--text-strong);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.drawerClose:focus-visible {
|
||||
outline: var(--line-strong) solid var(--accent-cyan);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.drawerBody {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 18px 32px;
|
||||
}
|
||||
.drawerSection {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.drawerSection:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.drawerH4 {
|
||||
margin: 0 0 9px;
|
||||
font-size: 9.5px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
.summaryBox {
|
||||
padding: 11px 12px;
|
||||
background: var(--surface-2);
|
||||
border: var(--line) solid var(--border-faint);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
}
|
||||
.statusRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 4px 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
.drDot {
|
||||
flex-shrink: 0;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.drDotOk {
|
||||
background: var(--accent-green);
|
||||
}
|
||||
.drDotWarn {
|
||||
background: var(--accent-yellow);
|
||||
}
|
||||
.drDotBad {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
.drDotMuted {
|
||||
background: var(--text-soft);
|
||||
}
|
||||
.drLabel {
|
||||
flex: 1;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.drState {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
.drStateOk {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
.drStateWarn {
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
.drStateBad {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
.drStateMuted {
|
||||
color: var(--text-soft);
|
||||
font-weight: 500;
|
||||
}
|
||||
.drawerActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.drawerGaugeRow {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.drawerNote {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.drawerLinks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.drawerLink {
|
||||
font-size: 10px;
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
}
|
||||
.drawerLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.soonCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
text-align: center;
|
||||
padding: 22px 16px;
|
||||
border-style: dashed;
|
||||
}
|
||||
.soonCard p {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-soft);
|
||||
max-width: 320px;
|
||||
}
|
||||
.roadmap {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
/* drawer sub-tabs (.dtabs / .dtab-panel) */
|
||||
.dtabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 11px;
|
||||
border-bottom: var(--line) solid var(--border-faint);
|
||||
}
|
||||
.dtabs button {
|
||||
padding: 6px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: var(--line-strong) solid transparent;
|
||||
color: var(--text-soft);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.dtabs button:hover {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.dtabActive {
|
||||
color: var(--text-strong) !important;
|
||||
border-bottom-color: var(--accent-cyan) !important;
|
||||
}
|
||||
.dtabPanel {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* gauge-as-button (hero Buildability) */
|
||||
.gaugeButton {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gaugeButton:focus-visible {
|
||||
outline: var(--line-strong) solid var(--accent-cyan);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ===================== RESPONSIVE ===================== */
|
||||
@media (max-width: 1500px) {
|
||||
.hero {
|
||||
|
|
@ -1438,3 +1686,8 @@
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.drawer {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
* DevelopmentScan — "Development Scan" section: a responsive grid of ScanCards
|
||||
* (Градостроительство / Ограничения / Инженерия / Рынок / Экономика / Риски /
|
||||
* Среда + ОКС). Every card's real-vs-placeholder content comes from the adapter.
|
||||
*
|
||||
* Each card maps to its drawer key and opens the matching detail drawer via the
|
||||
* `onOpenDrawer` callback threaded down from PticaPageContent.
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
|
|
@ -20,12 +23,14 @@ import {
|
|||
adaptOksCard,
|
||||
adaptRiskGauge,
|
||||
} from "@/components/site-finder/ptica/ptica-adapt";
|
||||
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||
|
||||
interface Props {
|
||||
analysis: ParcelAnalysis;
|
||||
onOpenDrawer: (key: DrawerKey) => void;
|
||||
}
|
||||
|
||||
export function DevelopmentScan({ analysis }: Props) {
|
||||
export function DevelopmentScan({ analysis, onOpenDrawer }: Props) {
|
||||
const riskGauge = adaptRiskGauge(analysis);
|
||||
|
||||
return (
|
||||
|
|
@ -34,12 +39,32 @@ export function DevelopmentScan({ analysis }: Props) {
|
|||
Development Scan · <b>градостроительный анализ</b>
|
||||
</div>
|
||||
<section className={styles.scanGrid}>
|
||||
<ScanCard card={adaptUrbanCard(analysis)} />
|
||||
<ScanCard card={adaptRestrictionsCard(analysis)} />
|
||||
<ScanCard card={adaptEngineeringCard(analysis)} />
|
||||
<ScanCard card={adaptMarketCard(analysis)} />
|
||||
<ScanCard
|
||||
card={adaptUrbanCard(analysis)}
|
||||
drawerKey="urban"
|
||||
onOpen={onOpenDrawer}
|
||||
/>
|
||||
<ScanCard
|
||||
card={adaptRestrictionsCard(analysis)}
|
||||
drawerKey="restrictions"
|
||||
onOpen={onOpenDrawer}
|
||||
/>
|
||||
<ScanCard
|
||||
card={adaptEngineeringCard(analysis)}
|
||||
drawerKey="engineering"
|
||||
onOpen={onOpenDrawer}
|
||||
/>
|
||||
<ScanCard
|
||||
card={adaptMarketCard(analysis)}
|
||||
drawerKey="market"
|
||||
onOpen={onOpenDrawer}
|
||||
/>
|
||||
<div id="economy">
|
||||
<ScanCard card={adaptEconomyCard()} />
|
||||
<ScanCard
|
||||
card={adaptEconomyCard()}
|
||||
drawerKey="economy"
|
||||
onOpen={onOpenDrawer}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -51,10 +76,23 @@ export function DevelopmentScan({ analysis }: Props) {
|
|||
Риски
|
||||
</h3>
|
||||
<BuildabilityGauge gauge={riskGauge} title="Сводный риск" />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.detailBtn}
|
||||
style={{ alignSelf: "flex-start", marginTop: "auto" }}
|
||||
onClick={() => onOpenDrawer("risks")}
|
||||
title="Открыть детализацию"
|
||||
>
|
||||
Подробнее
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ScanCard card={adaptEnvironmentCard()} />
|
||||
<ScanCard card={adaptOksCard()} />
|
||||
<ScanCard
|
||||
card={adaptEnvironmentCard()}
|
||||
drawerKey="environment"
|
||||
onOpen={onOpenDrawer}
|
||||
/>
|
||||
<ScanCard card={adaptOksCard()} drawerKey="oks" onOpen={onOpenDrawer} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,14 +11,20 @@ import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
|||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||
import type { ForecastReport } from "@/types/forecast";
|
||||
import { adaptInvestScore } from "@/components/site-finder/ptica/ptica-adapt";
|
||||
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||
|
||||
interface Props {
|
||||
analysis: ParcelAnalysis;
|
||||
/** §22 forecast report — when ready, surfaces the REAL invest-score / buy-signal. */
|
||||
forecastReport?: ForecastReport;
|
||||
onOpenDrawer: (key: DrawerKey) => void;
|
||||
}
|
||||
|
||||
export function InvestScoreBlock({ analysis, forecastReport }: Props) {
|
||||
export function InvestScoreBlock({
|
||||
analysis,
|
||||
forecastReport,
|
||||
onOpenDrawer,
|
||||
}: Props) {
|
||||
const inv = adaptInvestScore(analysis, forecastReport);
|
||||
|
||||
return (
|
||||
|
|
@ -63,6 +69,15 @@ export function InvestScoreBlock({ analysis, forecastReport }: Props) {
|
|||
{inv.buySignal.isReal ? inv.buySignal.value : "после прогноза"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.detailBtn}
|
||||
onClick={() => onOpenDrawer("potential")}
|
||||
title="Открыть расчёт ёмкости"
|
||||
>
|
||||
Подробнее
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,11 @@ import type {
|
|||
PticaField,
|
||||
PticaPassport,
|
||||
} from "@/components/site-finder/ptica/ptica-adapt";
|
||||
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||
|
||||
interface Props {
|
||||
analysis: ParcelAnalysis;
|
||||
onOpenDrawer: (key: DrawerKey) => void;
|
||||
}
|
||||
|
||||
function KvCell({
|
||||
|
|
@ -42,13 +44,21 @@ function KvCell({
|
|||
);
|
||||
}
|
||||
|
||||
export function ParcelPassportCard({ analysis }: Props) {
|
||||
export function ParcelPassportCard({ analysis, onOpenDrawer }: Props) {
|
||||
const p: PticaPassport = adaptPassport(analysis);
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.cardHead}>
|
||||
<h3 className={styles.cardTitle}>Паспорт участка</h3>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.detailBtn}
|
||||
onClick={() => onOpenDrawer("passport")}
|
||||
title="Открыть детализацию"
|
||||
>
|
||||
Подробнее
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.kvGrid}>
|
||||
<div className={`${styles.kv} ${styles.kvWide}`}>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
/**
|
||||
* PticaHero — top of the cockpit: map + passport KV-grid + Buildability gauge +
|
||||
* invest-score block, in the prototype's hero grid.
|
||||
*
|
||||
* The passport card, the Buildability gauge and the invest/potential block each
|
||||
* open their detail drawer via the `onOpenDrawer` callback threaded down from
|
||||
* PticaPageContent (passport / buildability / potential keys).
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
|
|
@ -13,25 +17,38 @@ import { ParcelPassportCard } from "@/components/site-finder/ptica/ParcelPasspor
|
|||
import { BuildabilityGauge } from "@/components/site-finder/ptica/BuildabilityGauge";
|
||||
import { InvestScoreBlock } from "@/components/site-finder/ptica/InvestScoreBlock";
|
||||
import { adaptBuildabilityGauge } from "@/components/site-finder/ptica/ptica-adapt";
|
||||
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||
|
||||
interface Props {
|
||||
analysis: ParcelAnalysis;
|
||||
/** §22 forecast report — threaded into the invest-score block once ready. */
|
||||
forecastReport?: ForecastReport;
|
||||
onOpenDrawer: (key: DrawerKey) => void;
|
||||
}
|
||||
|
||||
export function PticaHero({ analysis, forecastReport }: Props) {
|
||||
export function PticaHero({ analysis, forecastReport, onOpenDrawer }: Props) {
|
||||
const buildGauge = adaptBuildabilityGauge(analysis);
|
||||
|
||||
return (
|
||||
<section id="passport" className={styles.hero}>
|
||||
<PticaMap geojson={analysis.geom_geojson} />
|
||||
|
||||
<ParcelPassportCard analysis={analysis} />
|
||||
<ParcelPassportCard analysis={analysis} onOpenDrawer={onOpenDrawer} />
|
||||
|
||||
<div id="potential" className={`${styles.panel} ${styles.scorePanel}`}>
|
||||
<BuildabilityGauge gauge={buildGauge} title="Buildability Index" />
|
||||
<InvestScoreBlock analysis={analysis} forecastReport={forecastReport} />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.gaugeButton}
|
||||
onClick={() => onOpenDrawer("buildability")}
|
||||
aria-label="Открыть детализацию Buildability Index"
|
||||
>
|
||||
<BuildabilityGauge gauge={buildGauge} title="Buildability Index" />
|
||||
</button>
|
||||
<InvestScoreBlock
|
||||
analysis={analysis}
|
||||
forecastReport={forecastReport}
|
||||
onOpenDrawer={onOpenDrawer}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,22 +2,28 @@
|
|||
|
||||
/**
|
||||
* ScanCard — generic dark cockpit card: title (+ optional badge), metric rows,
|
||||
* and a "Подробнее" button that is RENDERED but disabled in INCREMENT 1 (drill-
|
||||
* down detail ships in a later release).
|
||||
* and a "Подробнее" button that opens the card's detail drawer.
|
||||
*
|
||||
* Each row's value flows from a {value, isReal, caption} view-model: placeholder
|
||||
* When `drawerKey` + `onOpen` are provided the button is ENABLED and opens the
|
||||
* matching drawer; without them it stays disabled (no drill-down wired). Each
|
||||
* row's value flows from a {value, isReal, caption} view-model: placeholder
|
||||
* fields are rendered muted with their caption so they never read as live data.
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import type { PticaScanCard } from "@/components/site-finder/ptica/ptica-adapt";
|
||||
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||
|
||||
interface Props {
|
||||
card: PticaScanCard;
|
||||
/** When set (with onOpen) the "Подробнее" button opens this drawer. */
|
||||
drawerKey?: DrawerKey;
|
||||
onOpen?: (key: DrawerKey) => void;
|
||||
}
|
||||
|
||||
export function ScanCard({ card }: Props) {
|
||||
export function ScanCard({ card, drawerKey, onOpen }: Props) {
|
||||
const { title, badge, rows, emptyState } = card;
|
||||
const canOpen = Boolean(drawerKey && onOpen);
|
||||
|
||||
return (
|
||||
<div className={`${styles.card} ${styles.scanCard}`}>
|
||||
|
|
@ -50,9 +56,14 @@ export function ScanCard({ card }: Props) {
|
|||
<button
|
||||
type="button"
|
||||
className={styles.detailBtn}
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
title="Детализация — в следующем релизе"
|
||||
disabled={!canOpen}
|
||||
aria-disabled={!canOpen}
|
||||
title={
|
||||
canOpen ? "Открыть детализацию" : "Детализация — в следующем релизе"
|
||||
}
|
||||
onClick={
|
||||
canOpen && drawerKey && onOpen ? () => onOpen(drawerKey) : undefined
|
||||
}
|
||||
>
|
||||
Подробнее
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,853 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* DrawerContent — per-key detail-drawer bodies, built from the typed drawer
|
||||
* view-models in ptica-adapt.ts (REAL data first, honest «—» placeholders for
|
||||
* absent fields). Each export renders the prototype's
|
||||
* summary → table/sub-tabs → source-line → optional export structure.
|
||||
*
|
||||
* Real-vs-placeholder lives entirely in the adapters; these components only
|
||||
* present the view-models, so a placeholder field is always styled muted with
|
||||
* its caption rather than shown as a live number.
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||
import type { ForecastReport } from "@/types/forecast";
|
||||
import type {
|
||||
DrawerKvRow,
|
||||
PticaField,
|
||||
PticaFactorBar,
|
||||
} from "@/components/site-finder/ptica/ptica-adapt";
|
||||
import {
|
||||
adaptPassportDrawer,
|
||||
adaptBuildabilityDrawer,
|
||||
adaptUrbanDrawer,
|
||||
adaptRestrictionsDrawer,
|
||||
adaptLegalDrawer,
|
||||
adaptEngineeringDrawer,
|
||||
adaptMarketDrawer,
|
||||
adaptEnvironmentDrawer,
|
||||
adaptGeorisksDrawer,
|
||||
adaptRisksDrawer,
|
||||
adaptProductDrawer,
|
||||
adaptPotentialDrawer,
|
||||
adaptEconomyDrawer,
|
||||
adaptFinanceDrawer,
|
||||
adaptOksDrawer,
|
||||
} from "@/components/site-finder/ptica/ptica-adapt";
|
||||
import { BuildabilityGauge } from "@/components/site-finder/ptica/BuildabilityGauge";
|
||||
import {
|
||||
DrawerSection,
|
||||
SummaryBox,
|
||||
DKvRow,
|
||||
StatusRow,
|
||||
DTable,
|
||||
HBars,
|
||||
DrawerTabs,
|
||||
ConfPill,
|
||||
SourceLine,
|
||||
DrawerActions,
|
||||
SoonCard,
|
||||
} from "@/components/site-finder/ptica/drawers/DrawerPrimitives";
|
||||
|
||||
const SRC_UPDATED = "источник: /analyze";
|
||||
|
||||
// ── shared helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Render a kv field value: muted span + caption tooltip for placeholders. */
|
||||
function FieldValue({ field }: { field: PticaField }) {
|
||||
if (field.isReal) return <>{field.value}</>;
|
||||
return (
|
||||
<span className={styles.kvPlaceholder} title={field.caption}>
|
||||
{field.value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function KvRows({ rows }: { rows: DrawerKvRow[] }) {
|
||||
return (
|
||||
<>
|
||||
{rows.map((r) => (
|
||||
<DKvRow
|
||||
key={r.k}
|
||||
k={r.k}
|
||||
v={<FieldValue field={r.field} />}
|
||||
muted={!r.field.isReal}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function factorBars(factors: PticaFactorBar[]) {
|
||||
return factors.map((f) => ({
|
||||
name: f.name,
|
||||
pct: f.pct,
|
||||
value: f.value,
|
||||
tone: f.tone,
|
||||
}));
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// passport
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function PassportContent({ analysis }: { analysis: ParcelAnalysis }) {
|
||||
const d = adaptPassportDrawer(analysis);
|
||||
return (
|
||||
<>
|
||||
<DrawerSection>
|
||||
<SummaryBox>{d.summary}</SummaryBox>
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection heading="ХАРАКТЕРИСТИКИ УЧАСТКА">
|
||||
<KvRows rows={d.registry} />
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection heading="ОГРАНИЧЕНИЯ · ЗОУИТ">
|
||||
<StatusRow
|
||||
label="Зоны с особыми условиями (ЗОУИТ)"
|
||||
state={d.zouitCount != null ? `${d.zouitCount}` : "нет данных НСПД"}
|
||||
tone={d.zouitCount != null && d.zouitCount > 0 ? "warn" : "ok"}
|
||||
/>
|
||||
{d.zouitOverlaps.length > 0 && (
|
||||
<DTable
|
||||
columns={[{ header: "Слой" }, { header: "Подкатегория" }]}
|
||||
rows={d.zouitOverlaps.map((z) => ({ cells: [z.name, z.sub] }))}
|
||||
/>
|
||||
)}
|
||||
</DrawerSection>
|
||||
|
||||
<SourceLine
|
||||
source="Источник: ЕГРН (Росреестр) · НСПД"
|
||||
updated={SRC_UPDATED}
|
||||
/>
|
||||
<DrawerSection>
|
||||
<DrawerActions labels={["Экспорт CSV", "Копировать"]} />
|
||||
</DrawerSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// buildability
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function BuildabilityContent({
|
||||
analysis,
|
||||
}: {
|
||||
analysis: ParcelAnalysis;
|
||||
}) {
|
||||
const d = adaptBuildabilityDrawer(analysis);
|
||||
return (
|
||||
<>
|
||||
<DrawerSection>
|
||||
<div className={styles.drawerGaugeRow}>
|
||||
<BuildabilityGauge gauge={d.gauge} title="Buildability Index" />
|
||||
</div>
|
||||
<SummaryBox>{d.summary}</SummaryBox>
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection heading="ВКЛАД ФАКТОРОВ · предв.">
|
||||
<HBars items={factorBars(d.factors)} />
|
||||
<DTable
|
||||
columns={[
|
||||
{ header: "Фактор" },
|
||||
{ header: "Оценка" },
|
||||
{ header: "Обоснование" },
|
||||
]}
|
||||
rows={d.factors.map((f) => ({ cells: [f.name, f.value, f.reason] }))}
|
||||
/>
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection heading="ГЕОМЕТРИЯ УЧАСТКА">
|
||||
<KvRows rows={d.geometry} />
|
||||
{d.penalties.length > 0 && (
|
||||
<DTable
|
||||
columns={[{ header: "Штраф" }, { header: "Влияние" }]}
|
||||
rows={d.penalties.map((p) => ({ cells: [p.label, p.impact] }))}
|
||||
/>
|
||||
)}
|
||||
</DrawerSection>
|
||||
|
||||
<SourceLine
|
||||
source="Источник: gate_verdict (НСПД) · geometry_suitability · utilities"
|
||||
updated={SRC_UPDATED}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// urban
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function UrbanContent({ analysis }: { analysis: ParcelAnalysis }) {
|
||||
const d = adaptUrbanDrawer(analysis);
|
||||
return (
|
||||
<>
|
||||
<DrawerSection>
|
||||
<SummaryBox>{d.summary}</SummaryBox>
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection heading="РЕГЛАМЕНТ ЗОНЫ">
|
||||
<KvRows rows={d.regulation} />
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection heading="НСПД · ЗОНИРОВАНИЕ">
|
||||
<StatusRow
|
||||
label="Территориальная зона"
|
||||
state={<FieldValue field={d.zoneCode} />}
|
||||
tone={d.zoneCode.isReal ? "ok" : "muted"}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Наименование зоны"
|
||||
state={<FieldValue field={d.zoneName} />}
|
||||
tone={d.zoneName.isReal ? "ok" : "muted"}
|
||||
/>
|
||||
</DrawerSection>
|
||||
|
||||
<SourceLine
|
||||
source="Источник: НСПД-зонирование (nspd_zoning) · ЕГРН"
|
||||
updated={SRC_UPDATED}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// restrictions
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function RestrictionsContent({
|
||||
analysis,
|
||||
}: {
|
||||
analysis: ParcelAnalysis;
|
||||
}) {
|
||||
const d = adaptRestrictionsDrawer(analysis);
|
||||
return (
|
||||
<>
|
||||
<DrawerSection>
|
||||
<SummaryBox>{d.summary}</SummaryBox>
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection heading="СТАТУС ОГРАНИЧЕНИЙ">
|
||||
{d.statusRows.map((r) => (
|
||||
<StatusRow
|
||||
key={r.label}
|
||||
label={r.label}
|
||||
state={r.state}
|
||||
tone={r.tone}
|
||||
/>
|
||||
))}
|
||||
</DrawerSection>
|
||||
|
||||
{d.zouitRows.length > 0 && (
|
||||
<DrawerSection heading="ПЕРЕСЕЧЕНИЯ ЗОУИТ">
|
||||
<DTable
|
||||
columns={[
|
||||
{ header: "Слой" },
|
||||
{ header: "Категория" },
|
||||
{ header: "Группа" },
|
||||
]}
|
||||
rows={d.zouitRows.map((z) => ({ cells: [z.layer, z.sub, z.reg] }))}
|
||||
/>
|
||||
</DrawerSection>
|
||||
)}
|
||||
|
||||
<SourceLine
|
||||
source="Источник: НСПД (nspd_zouit_overlaps) · ЕГРН"
|
||||
updated={SRC_UPDATED}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// legal
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function LegalContent({ analysis }: { analysis: ParcelAnalysis }) {
|
||||
const d = adaptLegalDrawer(analysis);
|
||||
return (
|
||||
<>
|
||||
<DrawerSection>
|
||||
<SummaryBox>{d.summary}</SummaryBox>
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection heading="ГЕЙТ-ВЕРДИКТ">
|
||||
<KvRows rows={d.gate} />
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection heading="ОБРЕМЕНЕНИЯ И ОГРАНИЧЕНИЯ">
|
||||
{d.restrictions.map((r) => (
|
||||
<StatusRow
|
||||
key={r.label}
|
||||
label={r.label}
|
||||
state={r.state}
|
||||
tone={r.tone}
|
||||
/>
|
||||
))}
|
||||
<KvRows rows={d.registry} />
|
||||
</DrawerSection>
|
||||
|
||||
<SourceLine
|
||||
source="Источник: gate_verdict · ЕГРН · НСПД"
|
||||
updated={SRC_UPDATED}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// engineering
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function EngineeringContent({ analysis }: { analysis: ParcelAnalysis }) {
|
||||
const d = adaptEngineeringDrawer(analysis);
|
||||
return (
|
||||
<>
|
||||
<DrawerSection>
|
||||
<SummaryBox>{d.summary}</SummaryBox>
|
||||
</DrawerSection>
|
||||
|
||||
{d.hasData ? (
|
||||
<>
|
||||
<DrawerSection heading="БЛИЖАЙШИЕ ПОДКЛЮЧЕНИЯ (НСПД)">
|
||||
<DTable
|
||||
columns={[
|
||||
{ header: "Ресурс" },
|
||||
{ header: "Дистанция", num: true },
|
||||
{ header: "Объект" },
|
||||
{ header: "В радиусе 2 км", num: true },
|
||||
]}
|
||||
rows={d.rows.map((r) => ({
|
||||
cells: [r.label, r.nearest, r.name, r.count],
|
||||
}))}
|
||||
/>
|
||||
</DrawerSection>
|
||||
<DrawerSection heading="УДАЛЁННОСТЬ ОТ УЗЛОВ">
|
||||
<HBars items={factorBars(d.bars)} />
|
||||
</DrawerSection>
|
||||
</>
|
||||
) : (
|
||||
<DrawerSection>
|
||||
<div className={styles.emptyState}>
|
||||
Нет данных по инженерным сетям
|
||||
</div>
|
||||
</DrawerSection>
|
||||
)}
|
||||
|
||||
<SourceLine
|
||||
source="Источник: utilities.summary (НСПД)"
|
||||
updated={SRC_UPDATED}
|
||||
/>
|
||||
{d.hasData && (
|
||||
<DrawerSection>
|
||||
<DrawerActions labels={["Экспорт CSV", "Копировать"]} />
|
||||
</DrawerSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// market (4 sub-tabs)
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function MarketContent({ analysis }: { analysis: ParcelAnalysis }) {
|
||||
const d = adaptMarketDrawer(analysis);
|
||||
|
||||
const compPanel = (
|
||||
<>
|
||||
{d.competitors.length > 0 ? (
|
||||
<DTable
|
||||
columns={[
|
||||
{ header: "ЖК" },
|
||||
{ header: "Застройщик" },
|
||||
{ header: "Класс" },
|
||||
{ header: "Квартир", num: true },
|
||||
{ header: "Дист.", num: true },
|
||||
{ header: "Статус" },
|
||||
]}
|
||||
rows={d.competitors.map((c) => ({
|
||||
cells: [c.name, c.dev, c.cls, c.flats, c.distance, c.status],
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.emptyState}>Конкуренты не найдены</div>
|
||||
)}
|
||||
<DKvRow
|
||||
k="Медиана цены района"
|
||||
v={<FieldValue field={d.medianField} />}
|
||||
muted={!d.medianField.isReal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const planPanel = (
|
||||
<>
|
||||
<SummaryBox>{d.plan.note}</SummaryBox>
|
||||
{d.plan.available && d.plan.pipelineByClass.length > 0 && (
|
||||
<DTable
|
||||
columns={[{ header: "Класс" }, { header: "Проектов", num: true }]}
|
||||
rows={d.plan.pipelineByClass.map((p) => ({
|
||||
cells: [p.cls, String(p.count)],
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const speedPanel = d.speed.available ? (
|
||||
<>
|
||||
<DKvRow k="Velocity score" v={d.speed.velocityScore} />
|
||||
<DKvRow k="Темп (объект)" v={d.speed.monthly} />
|
||||
<DKvRow k="Медиана ЕКБ" v={d.speed.median} />
|
||||
<DKvRow k="Уверенность" v={d.speed.confidence} />
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.emptyState}>Скорость — после прогноза</div>
|
||||
);
|
||||
|
||||
const trendPanel = d.trend.available ? (
|
||||
<>
|
||||
<DKvRow k="Δ цены за 6 мес" v={<FieldValue field={d.trend.field} />} />
|
||||
<DKvRow k="Текущая средняя" v={d.trend.recent} />
|
||||
<DKvRow k="Прошлая средняя" v={d.trend.prior} />
|
||||
<DKvRow k="Сделок (период)" v={d.trend.count} />
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.emptyState}>Тренд — после прогноза</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawerSection>
|
||||
<SummaryBox>{d.summary}</SummaryBox>
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection heading="СРАВНЕНИЕ И ДИНАМИКА">
|
||||
<DrawerTabs
|
||||
tabs={[
|
||||
{ id: "comp", label: "Конкуренты", content: compPanel },
|
||||
{ id: "plan", label: "Планировки", content: planPanel },
|
||||
{ id: "speed", label: "Скорость", content: speedPanel },
|
||||
{ id: "trend", label: "Тренд", content: trendPanel },
|
||||
]}
|
||||
/>
|
||||
</DrawerSection>
|
||||
|
||||
<SourceLine
|
||||
source="Источник: competitors · market_trend · velocity · pipeline_24mo"
|
||||
updated={SRC_UPDATED}
|
||||
/>
|
||||
{d.competitors.length > 0 && (
|
||||
<DrawerSection>
|
||||
<DrawerActions labels={["Экспорт CSV", "Копировать"]} />
|
||||
</DrawerSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// environment (Среда — 4 sub-tabs)
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function EnvironmentContent({ analysis }: { analysis: ParcelAnalysis }) {
|
||||
const d = adaptEnvironmentDrawer(analysis);
|
||||
|
||||
const noisePanel = (
|
||||
<>
|
||||
<KvRows rows={d.noise.rows} />
|
||||
{d.noise.sources.length > 0 && (
|
||||
<DTable
|
||||
columns={[
|
||||
{ header: "Источник" },
|
||||
{ header: "дБ", num: true },
|
||||
{ header: "Дист.", num: true },
|
||||
]}
|
||||
rows={d.noise.sources.map((s) => ({ cells: [s.name, s.db, s.dist] }))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawerSection>
|
||||
<SummaryBox>{d.summary}</SummaryBox>
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection heading="ЭКОЛОГИЯ И КЛИМАТ">
|
||||
<DrawerTabs
|
||||
tabs={[
|
||||
{ id: "noise", label: "Шум", content: noisePanel },
|
||||
{
|
||||
id: "air",
|
||||
label: "Воздух",
|
||||
content: <KvRows rows={d.air.rows} />,
|
||||
},
|
||||
{
|
||||
id: "wind",
|
||||
label: "Ветер",
|
||||
content: <KvRows rows={d.wind.rows} />,
|
||||
},
|
||||
{
|
||||
id: "weather",
|
||||
label: "Погода",
|
||||
content: <KvRows rows={d.weather.rows} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DrawerSection>
|
||||
|
||||
<SourceLine
|
||||
source="Источник: noise · air_quality · wind · weather (/analyze)"
|
||||
updated={SRC_UPDATED}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// georisks (3 sub-tabs)
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function GeorisksContent({ analysis }: { analysis: ParcelAnalysis }) {
|
||||
const d = adaptGeorisksDrawer(analysis);
|
||||
|
||||
const geologyPanel = (
|
||||
<>
|
||||
<KvRows rows={d.geology.rows} />
|
||||
{d.geology.links.length > 0 && (
|
||||
<div className={styles.drawerLinks}>
|
||||
{d.geology.links.map((l) => (
|
||||
<a
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.drawerLink}
|
||||
>
|
||||
{l.label} →
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawerSection>
|
||||
<SummaryBox>{d.summary}</SummaryBox>
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection heading="ИНЖЕНЕРНО-ГЕОЛОГИЧЕСКИЕ УСЛОВИЯ">
|
||||
<DrawerTabs
|
||||
tabs={[
|
||||
{ id: "geology", label: "Геология", content: geologyPanel },
|
||||
{
|
||||
id: "hydro",
|
||||
label: "Гидрология",
|
||||
content: <KvRows rows={d.hydrology.rows} />,
|
||||
},
|
||||
{
|
||||
id: "geotech",
|
||||
label: "Геотехника",
|
||||
content: <KvRows rows={d.geotech.rows} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DrawerSection>
|
||||
|
||||
<SourceLine
|
||||
source="Источник: geology · hydrology · geotech_risk (/analyze)"
|
||||
updated={SRC_UPDATED}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// risks (composite, derived)
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function RisksContent({ analysis }: { analysis: ParcelAnalysis }) {
|
||||
const d = adaptRisksDrawer(analysis);
|
||||
return (
|
||||
<>
|
||||
<DrawerSection>
|
||||
<div className={styles.drawerGaugeRow}>
|
||||
<BuildabilityGauge gauge={d.gauge} title="Сводный риск" />
|
||||
</div>
|
||||
<SummaryBox>{d.summary}</SummaryBox>
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection heading="РАЗЛОЖЕНИЕ РИСКА · предв.">
|
||||
<HBars items={factorBars(d.breakdown)} />
|
||||
<DTable
|
||||
columns={[
|
||||
{ header: "Компонент" },
|
||||
{ header: "Оценка" },
|
||||
{ header: "Источник" },
|
||||
]}
|
||||
rows={d.breakdown.map((f) => ({
|
||||
cells: [f.name, f.value, f.reason],
|
||||
}))}
|
||||
/>
|
||||
</DrawerSection>
|
||||
|
||||
{(d.blockers.length > 0 || d.warnings.length > 0) && (
|
||||
<DrawerSection heading="ГЕЙТ · БЛОКЕРЫ И ПРЕДУПРЕЖДЕНИЯ">
|
||||
{d.blockers.map((b) => (
|
||||
<StatusRow
|
||||
key={b.code}
|
||||
label={b.detail}
|
||||
state="блокер"
|
||||
tone="bad"
|
||||
/>
|
||||
))}
|
||||
{d.warnings.map((w) => (
|
||||
<StatusRow
|
||||
key={w.code}
|
||||
label={w.detail}
|
||||
state="предупр."
|
||||
tone="warn"
|
||||
/>
|
||||
))}
|
||||
</DrawerSection>
|
||||
)}
|
||||
|
||||
<SourceLine
|
||||
source="Источник: gate_verdict · geometry_suitability · noise"
|
||||
updated={SRC_UPDATED}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// product (§22 product_tz)
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function ProductContent({
|
||||
forecastReport,
|
||||
}: {
|
||||
forecastReport?: ForecastReport;
|
||||
}) {
|
||||
const d = adaptProductDrawer(forecastReport);
|
||||
|
||||
if (!d.available) {
|
||||
return (
|
||||
<>
|
||||
<DrawerSection>
|
||||
<SummaryBox>{d.summary}</SummaryBox>
|
||||
</DrawerSection>
|
||||
<DrawerSection>
|
||||
<div className={styles.emptyState}>После прогноза (Сценарии)</div>
|
||||
</DrawerSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawerSection>
|
||||
<SummaryBox>{d.summary}</SummaryBox>
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection heading="РЕКОМЕНДОВАННЫЙ КЛАСС">
|
||||
<DKvRow
|
||||
k="Класс продукта"
|
||||
v={
|
||||
d.objClass.isReal ? (
|
||||
<ConfPill level="high">{d.objClass.value}</ConfPill>
|
||||
) : (
|
||||
<FieldValue field={d.objClass} />
|
||||
)
|
||||
}
|
||||
muted={!d.objClass.isReal}
|
||||
/>
|
||||
</DrawerSection>
|
||||
|
||||
{d.mix.length > 0 && (
|
||||
<DrawerSection heading="КВАРТИРОГРАФИЯ">
|
||||
<HBars
|
||||
items={d.mix.map((m) => ({
|
||||
name: m.bucket,
|
||||
pct: m.pct,
|
||||
value: m.signal,
|
||||
tone: "neutral",
|
||||
}))}
|
||||
/>
|
||||
</DrawerSection>
|
||||
)}
|
||||
|
||||
{d.usp.length > 0 && (
|
||||
<DrawerSection heading="USP-НИШИ">
|
||||
<DTable
|
||||
columns={[{ header: "Ниша" }, { header: "Класс" }]}
|
||||
rows={d.usp.map((u) => ({ cells: [u.text, u.cls] }))}
|
||||
/>
|
||||
</DrawerSection>
|
||||
)}
|
||||
|
||||
{d.reasons.length > 0 && (
|
||||
<DrawerSection heading="ОБОСНОВАНИЕ">
|
||||
{d.reasons.map((r, i) => (
|
||||
<p key={i} className={styles.drawerNote}>
|
||||
{r}
|
||||
</p>
|
||||
))}
|
||||
</DrawerSection>
|
||||
)}
|
||||
|
||||
<SourceLine
|
||||
source="Источник: §22 product_tz (advisory)"
|
||||
updated={SRC_UPDATED}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// potential
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function PotentialContent({ analysis }: { analysis: ParcelAnalysis }) {
|
||||
const d = adaptPotentialDrawer(analysis);
|
||||
return (
|
||||
<>
|
||||
<DrawerSection>
|
||||
<SummaryBox>{d.summary}</SummaryBox>
|
||||
</DrawerSection>
|
||||
<DrawerSection heading="ЦЕПОЧКА РАСЧЁТА ЁМКОСТИ">
|
||||
<KvRows rows={d.rows} />
|
||||
</DrawerSection>
|
||||
<SourceLine
|
||||
source="Источник: geometry_suitability · НСПД-зонирование (max_far)"
|
||||
updated={SRC_UPDATED}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// economy / finance
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function EconomyContent() {
|
||||
const d = adaptEconomyDrawer();
|
||||
return (
|
||||
<>
|
||||
<DrawerSection>
|
||||
<SummaryBox>{d.summary}</SummaryBox>
|
||||
</DrawerSection>
|
||||
<DrawerSection heading="КЛЮЧЕВЫЕ ПОКАЗАТЕЛИ · после финмодели">
|
||||
<KvRows rows={d.rows} />
|
||||
</DrawerSection>
|
||||
<SourceLine source="Источник: финмодель §22 (асинхронно)" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FinanceContent() {
|
||||
const d = adaptFinanceDrawer();
|
||||
return (
|
||||
<>
|
||||
<DrawerSection>
|
||||
<SummaryBox>{d.summary}</SummaryBox>
|
||||
</DrawerSection>
|
||||
<DrawerSection heading="ФИНАНСОВАЯ МОДЕЛЬ · после финмодели">
|
||||
<KvRows rows={d.rows} />
|
||||
</DrawerSection>
|
||||
<SourceLine source="Источник: финмодель §22 (DCF, асинхронно)" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// oks
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function OksContent() {
|
||||
const d = adaptOksDrawer();
|
||||
return (
|
||||
<>
|
||||
<DrawerSection>
|
||||
<SummaryBox>{d.summary}</SummaryBox>
|
||||
</DrawerSection>
|
||||
<DrawerSection>
|
||||
<div className={styles.emptyState}>Нет данных</div>
|
||||
</DrawerSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// insolation / future3d — СКОРО (3D module lands in PR#5)
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function InsolationContent() {
|
||||
return (
|
||||
<>
|
||||
<DrawerSection>
|
||||
<SoonCard
|
||||
text="Расчёт инсоляции и теней (КЕО, продолжительность инсоляции по СанПиН) по 3D-массе застройки и окружению."
|
||||
roadmap="Roadmap · после 3D-модуля (PR#5)"
|
||||
/>
|
||||
</DrawerSection>
|
||||
<DrawerSection heading="ЧТО БУДЕТ В МОДУЛЕ">
|
||||
<StatusRow
|
||||
label="Карта инсоляции фасадов"
|
||||
state="в разработке"
|
||||
tone="warn"
|
||||
/>
|
||||
<StatusRow
|
||||
label="Теневой анализ по сезонам"
|
||||
state="в разработке"
|
||||
tone="warn"
|
||||
/>
|
||||
<StatusRow
|
||||
label="Проверка норм СанПиН / КЕО"
|
||||
state="в разработке"
|
||||
tone="warn"
|
||||
/>
|
||||
</DrawerSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Future3dContent() {
|
||||
return (
|
||||
<>
|
||||
<DrawerSection>
|
||||
<SoonCard
|
||||
text="Объёмная модель застройки по КСИТ / пятну / высоте с проверкой норм и предпросмотром продукта."
|
||||
roadmap="Roadmap · PR#5 (Three.js)"
|
||||
/>
|
||||
</DrawerSection>
|
||||
<DrawerSection heading="СОСТАВ МОДУЛЯ">
|
||||
<StatusRow
|
||||
label="Генеративная масса по регламенту"
|
||||
state="в разработке"
|
||||
tone="warn"
|
||||
/>
|
||||
<StatusRow
|
||||
label="Этажность и плотность 3D"
|
||||
state="в разработке"
|
||||
tone="warn"
|
||||
/>
|
||||
<StatusRow
|
||||
label="Экспорт в DXF / IFC"
|
||||
state="в разработке"
|
||||
tone="warn"
|
||||
/>
|
||||
</DrawerSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* DrawerPrimitives — typed React port of the prototype's drawer markup vocabulary
|
||||
* (`.drawer-section` → `.summary-box` → table / kvrows / status-rows / hbars →
|
||||
* `.source-line` → `.drawer-actions`). Pure presentational building blocks the
|
||||
* per-key drawer content modules compose; no data logic lives here.
|
||||
*
|
||||
* Sub-tabs (`.dtabs` / `.dtab-panel`) are a self-contained client component
|
||||
* (DrawerTabs) that manages the active tab with local useState — the cockpit's
|
||||
* market / environment / georisks drawers reuse it.
|
||||
*/
|
||||
|
||||
import { useId, useState } from "react";
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
|
||||
// ── Section + summary ─────────────────────────────────────────────────────────
|
||||
|
||||
export function DrawerSection({
|
||||
heading,
|
||||
children,
|
||||
}: {
|
||||
heading?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.drawerSection}>
|
||||
{heading && <h4 className={styles.drawerH4}>{heading}</h4>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SummaryBox({ children }: { children: React.ReactNode }) {
|
||||
return <div className={styles.summaryBox}>{children}</div>;
|
||||
}
|
||||
|
||||
// ── Key/value rows ────────────────────────────────────────────────────────────
|
||||
|
||||
export function DKvRow({
|
||||
k,
|
||||
v,
|
||||
muted,
|
||||
}: {
|
||||
k: React.ReactNode;
|
||||
v: React.ReactNode;
|
||||
muted?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`${styles.kvrow} ${muted ? styles.kvrowPlaceholder : ""}`}>
|
||||
<span className={styles.k}>{k}</span>
|
||||
<span className={styles.v}>{v}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Status row (dot + label + state pill) ─────────────────────────────────────
|
||||
|
||||
export type DrawerTone = "ok" | "warn" | "bad" | "muted";
|
||||
|
||||
export function StatusRow({
|
||||
label,
|
||||
state,
|
||||
tone = "muted",
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
state: React.ReactNode;
|
||||
tone?: DrawerTone;
|
||||
}) {
|
||||
const dotClass =
|
||||
tone === "ok"
|
||||
? styles.drDotOk
|
||||
: tone === "warn"
|
||||
? styles.drDotWarn
|
||||
: tone === "bad"
|
||||
? styles.drDotBad
|
||||
: styles.drDotMuted;
|
||||
const stateClass =
|
||||
tone === "ok"
|
||||
? styles.drStateOk
|
||||
: tone === "warn"
|
||||
? styles.drStateWarn
|
||||
: tone === "bad"
|
||||
? styles.drStateBad
|
||||
: styles.drStateMuted;
|
||||
return (
|
||||
<div className={styles.statusRow}>
|
||||
<span className={`${styles.drDot} ${dotClass}`} aria-hidden="true" />
|
||||
<span className={styles.drLabel}>{label}</span>
|
||||
<span className={`${styles.drState} ${stateClass}`}>{state}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Data table ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DTableColumn {
|
||||
header: string;
|
||||
/** true → right-aligned mono cell (numbers). */
|
||||
num?: boolean;
|
||||
}
|
||||
|
||||
export interface DTableRow {
|
||||
cells: React.ReactNode[];
|
||||
/** highlight (e.g. totals / target). */
|
||||
emphasis?: boolean;
|
||||
}
|
||||
|
||||
export function DTable({
|
||||
columns,
|
||||
rows,
|
||||
}: {
|
||||
columns: DTableColumn[];
|
||||
rows: DTableRow[];
|
||||
}) {
|
||||
return (
|
||||
<table className={styles.dtable}>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((c, i) => (
|
||||
<th key={i} className={c.num ? styles.thNum : undefined}>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, ri) => (
|
||||
<tr key={ri} className={r.emphasis ? styles.rowTarget : undefined}>
|
||||
{r.cells.map((cell, ci) => (
|
||||
<td
|
||||
key={ci}
|
||||
className={columns[ci]?.num ? styles.tdNum : undefined}
|
||||
>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Horizontal bars (квартирография / contributions) ──────────────────────────
|
||||
|
||||
export interface HBarItem {
|
||||
name: string;
|
||||
/** 0..100 fill width. */
|
||||
pct: number;
|
||||
value: React.ReactNode;
|
||||
tone?: "good" | "bad" | "neutral";
|
||||
}
|
||||
|
||||
export function HBars({ items }: { items: HBarItem[] }) {
|
||||
return (
|
||||
<div className={styles.hbars}>
|
||||
{items.map((b, i) => {
|
||||
const fillClass =
|
||||
b.tone === "good"
|
||||
? styles.hbarFillGood
|
||||
: b.tone === "bad"
|
||||
? styles.hbarFillBad
|
||||
: styles.hbarFillNeutral;
|
||||
return (
|
||||
<div key={i} className={styles.hbar}>
|
||||
<span className={styles.hbarName}>{b.name}</span>
|
||||
<span className={styles.hbarTrack}>
|
||||
<span
|
||||
className={`${styles.hbarFill} ${fillClass}`}
|
||||
style={{ width: `${Math.max(0, Math.min(100, b.pct))}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className={styles.hbarValue}>{b.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Confidence pill / tag ─────────────────────────────────────────────────────
|
||||
|
||||
export function ConfPill({
|
||||
level,
|
||||
children,
|
||||
}: {
|
||||
level: "high" | "medium" | "low";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const cls =
|
||||
level === "high"
|
||||
? styles.confPillHigh
|
||||
: level === "medium"
|
||||
? styles.confPillMedium
|
||||
: styles.confPillLow;
|
||||
return <span className={`${styles.confPill} ${cls}`}>{children}</span>;
|
||||
}
|
||||
|
||||
export function Tag({ children }: { children: React.ReactNode }) {
|
||||
return <span className={styles.tag}>{children}</span>;
|
||||
}
|
||||
|
||||
// ── Source line + export actions ──────────────────────────────────────────────
|
||||
|
||||
export function SourceLine({
|
||||
source,
|
||||
updated,
|
||||
}: {
|
||||
source: string;
|
||||
updated?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.sourceLine}>
|
||||
<span>{source}</span>
|
||||
{updated && <span>{updated}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DrawerActions — light export/copy buttons (kept inert: the cockpit's drawer
|
||||
* exports are not wired to an endpoint yet, so these are disabled placeholders
|
||||
* matching the prototype's `.drawer-actions` rather than fake-firing a download).
|
||||
*/
|
||||
export function DrawerActions({ labels }: { labels: string[] }) {
|
||||
return (
|
||||
<div className={styles.drawerActions}>
|
||||
{labels.map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
className={styles.detailBtn}
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
title="Экспорт — в следующем релизе"
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Placeholder card (СКОРО) ──────────────────────────────────────────────────
|
||||
|
||||
export function SoonCard({
|
||||
text,
|
||||
roadmap,
|
||||
}: {
|
||||
text: string;
|
||||
roadmap?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`${styles.card} ${styles.soonCard}`}>
|
||||
<div className={styles.soon}>СКОРО</div>
|
||||
<p>{text}</p>
|
||||
{roadmap && <div className={styles.roadmap}>{roadmap}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-tabs (.dtabs / .dtab-panel) ───────────────────────────────────────────
|
||||
|
||||
export interface DrawerTab {
|
||||
id: string;
|
||||
label: string;
|
||||
content: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DrawerTabs({ tabs }: { tabs: DrawerTab[] }) {
|
||||
const [active, setActive] = useState<string>(tabs[0]?.id ?? "");
|
||||
const groupId = useId();
|
||||
return (
|
||||
<>
|
||||
<div className={styles.dtabs} role="tablist">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`${groupId}-tab-${t.id}`}
|
||||
aria-selected={active === t.id}
|
||||
aria-controls={`${groupId}-panel-${t.id}`}
|
||||
className={active === t.id ? styles.dtabActive : ""}
|
||||
onClick={() => setActive(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tabs.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
role="tabpanel"
|
||||
id={`${groupId}-panel-${t.id}`}
|
||||
aria-labelledby={`${groupId}-tab-${t.id}`}
|
||||
hidden={active !== t.id}
|
||||
className={styles.dtabPanel}
|
||||
>
|
||||
{active === t.id && t.content}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* PticaDrawer — controlled right-slider detail drawer (CoStar drill-down pattern,
|
||||
* per ui-conventions.md: 480px desktop, full-screen on phone).
|
||||
*
|
||||
* Behaviour ported from the prototype `openDrawer`/`closeDrawer` (app.js):
|
||||
* - scrim click closes,
|
||||
* - Escape closes,
|
||||
* - the close button is focused on open (a11y),
|
||||
* - body scroll is locked while open.
|
||||
*
|
||||
* `role="dialog"` + `aria-modal` + `aria-label` make it a proper modal surface.
|
||||
* The component is fully controlled — `open` drives mount/visibility, the host
|
||||
* (PticaPageContent) owns the open-key state and URL deep-link.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
/** Sub-title under the drawer title (e.g. "Полные данные ЕГРН / НСПД"). */
|
||||
sub?: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PticaDrawer({ open, title, sub, onClose, children }: Props) {
|
||||
const closeRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
// Esc-to-close + body-scroll-lock + focus the close button on open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
// Focus the close button so keyboard users land inside the dialog.
|
||||
closeRef.current?.focus();
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`${styles.drawerScrim} ${open ? styles.drawerScrimOpen : ""}`}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<aside
|
||||
className={`${styles.drawer} ${open ? styles.drawerOpen : ""}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div className={styles.drawerHead}>
|
||||
<div>
|
||||
<h3 className={styles.drawerTitle}>{title}</h3>
|
||||
{sub && <div className={styles.drawerSub}>{sub}</div>}
|
||||
</div>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
className={styles.drawerClose}
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none">
|
||||
<path
|
||||
d="M6 6l12 12M18 6L6 18"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.drawerBody}>{open && children}</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* DrawerKey — the canonical string-literal union of every detail-drawer the
|
||||
* cockpit can open. Kept in its own (pure, no-React) module so both the trigger
|
||||
* components (ScanCard / hero cards) and the registry import the same union
|
||||
* without pulling in React content.
|
||||
*
|
||||
* Keys mirror the prototype `#drawer-templates` (`<template data-key="...">`) so
|
||||
* deep-links (`?drawer=market`) line up 1:1 with the redesign reference.
|
||||
*/
|
||||
|
||||
export const DRAWER_KEYS = [
|
||||
"passport",
|
||||
"buildability",
|
||||
"urban",
|
||||
"restrictions",
|
||||
"engineering",
|
||||
"market",
|
||||
"economy",
|
||||
"risks",
|
||||
"georisks",
|
||||
"oks",
|
||||
"potential",
|
||||
"product",
|
||||
"finance",
|
||||
"legal",
|
||||
"environment",
|
||||
"insolation",
|
||||
"future3d",
|
||||
] as const;
|
||||
|
||||
export type DrawerKey = (typeof DRAWER_KEYS)[number];
|
||||
|
||||
/** Narrow an arbitrary string (e.g. a `?drawer=` query value) to a DrawerKey. */
|
||||
export function asDrawerKey(
|
||||
value: string | null | undefined,
|
||||
): DrawerKey | null {
|
||||
if (!value) return null;
|
||||
return (DRAWER_KEYS as readonly string[]).includes(value)
|
||||
? (value as DrawerKey)
|
||||
: null;
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* drawer-registry — maps each DrawerKey → { title, sub, content }, building the
|
||||
* content node from `analysis` (ParcelAnalysis) + optional `forecastReport`
|
||||
* (ForecastReport). PticaPageContent renders the matching entry inside a single
|
||||
* controlled <PticaDrawer>. Titles / subs mirror the prototype `data-title` /
|
||||
* `data-sub`.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||
import type { ForecastReport } from "@/types/forecast";
|
||||
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||
import {
|
||||
PassportContent,
|
||||
BuildabilityContent,
|
||||
UrbanContent,
|
||||
RestrictionsContent,
|
||||
LegalContent,
|
||||
EngineeringContent,
|
||||
MarketContent,
|
||||
EnvironmentContent,
|
||||
GeorisksContent,
|
||||
RisksContent,
|
||||
ProductContent,
|
||||
PotentialContent,
|
||||
EconomyContent,
|
||||
FinanceContent,
|
||||
OksContent,
|
||||
InsolationContent,
|
||||
Future3dContent,
|
||||
} from "@/components/site-finder/ptica/drawers/DrawerContent";
|
||||
|
||||
export interface DrawerEntry {
|
||||
title: string;
|
||||
sub: string;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
export function getDrawerEntry(
|
||||
key: DrawerKey,
|
||||
analysis: ParcelAnalysis,
|
||||
forecastReport?: ForecastReport,
|
||||
): DrawerEntry {
|
||||
switch (key) {
|
||||
case "passport":
|
||||
return {
|
||||
title: "Паспорт участка",
|
||||
sub: "Полные данные ЕГРН / НСПД",
|
||||
content: <PassportContent analysis={analysis} />,
|
||||
};
|
||||
case "buildability":
|
||||
return {
|
||||
title: "Buildability Index",
|
||||
sub: "Из чего складывается оценка · предв.",
|
||||
content: <BuildabilityContent analysis={analysis} />,
|
||||
};
|
||||
case "urban":
|
||||
return {
|
||||
title: "Градостроительство",
|
||||
sub: "ПЗЗ · регламент · НСПД",
|
||||
content: <UrbanContent analysis={analysis} />,
|
||||
};
|
||||
case "restrictions":
|
||||
return {
|
||||
title: "Ограничения · ЗОУИТ",
|
||||
sub: "Зоны с особыми условиями",
|
||||
content: <RestrictionsContent analysis={analysis} />,
|
||||
};
|
||||
case "engineering":
|
||||
return {
|
||||
title: "Инженерия",
|
||||
sub: "Точки подключения ресурсов",
|
||||
content: <EngineeringContent analysis={analysis} />,
|
||||
};
|
||||
case "market":
|
||||
return {
|
||||
title: "Рынок",
|
||||
sub: "Конкуренты · планировки · скорость · тренд",
|
||||
content: <MarketContent analysis={analysis} />,
|
||||
};
|
||||
case "economy":
|
||||
return {
|
||||
title: "Экономика",
|
||||
sub: "Предварительная оценка · после финмодели",
|
||||
content: <EconomyContent />,
|
||||
};
|
||||
case "risks":
|
||||
return {
|
||||
title: "Сводный риск",
|
||||
sub: "Композиция факторов · предв.",
|
||||
content: <RisksContent analysis={analysis} />,
|
||||
};
|
||||
case "georisks":
|
||||
return {
|
||||
title: "Геориски",
|
||||
sub: "Геология · гидрология · геотехника",
|
||||
content: <GeorisksContent analysis={analysis} />,
|
||||
};
|
||||
case "oks":
|
||||
return {
|
||||
title: "Объекты кап. строительства",
|
||||
sub: "ОКС на участке",
|
||||
content: <OksContent />,
|
||||
};
|
||||
case "potential":
|
||||
return {
|
||||
title: "Расчёт ёмкости",
|
||||
sub: "Плотность · пятно · продаваемая",
|
||||
content: <PotentialContent analysis={analysis} />,
|
||||
};
|
||||
case "product":
|
||||
return {
|
||||
title: "Продуктовая стратегия",
|
||||
sub: "Рекомендуемый класс и квартирография",
|
||||
content: <ProductContent forecastReport={forecastReport} />,
|
||||
};
|
||||
case "finance":
|
||||
return {
|
||||
title: "Финансовая модель",
|
||||
sub: "Investment Clearance",
|
||||
content: <FinanceContent />,
|
||||
};
|
||||
case "legal":
|
||||
return {
|
||||
title: "Правовой статус",
|
||||
sub: "Обременения, ограничения и регламент",
|
||||
content: <LegalContent analysis={analysis} />,
|
||||
};
|
||||
case "environment":
|
||||
return {
|
||||
title: "Среда · Экология",
|
||||
sub: "Шум · воздух · ветер · погода",
|
||||
content: <EnvironmentContent analysis={analysis} />,
|
||||
};
|
||||
case "insolation":
|
||||
return {
|
||||
title: "Инсоляционная матрица",
|
||||
sub: "Планируется в следующей версии",
|
||||
content: <InsolationContent />,
|
||||
};
|
||||
case "future3d":
|
||||
return {
|
||||
title: "3D-масса застройки",
|
||||
sub: "Генеративная масса по регламенту",
|
||||
content: <Future3dContent />,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -10,8 +10,11 @@
|
|||
* truth for the real-vs-placeholder decision.
|
||||
*/
|
||||
|
||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||
import type { ForecastReport } from "@/types/forecast";
|
||||
import type {
|
||||
ParcelAnalysis,
|
||||
ParcelAnalysisCompetitor,
|
||||
} from "@/types/site-finder";
|
||||
import type { ForecastReport, ProductMixEntry } from "@/types/forecast";
|
||||
|
||||
// ── View-model ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -423,6 +426,950 @@ export function adaptOksCard(): PticaScanCard {
|
|||
};
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// DRAWER-LEVEL ADAPTERS (PR#4) — typed view-models, real-vs-placeholder centralized
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Each drawer consumes a typed view-model below rather than reaching into
|
||||
// ParcelAnalysis / ForecastReport in the React tree. REAL data is surfaced where
|
||||
// the backend provides it; absent fields become honest `{value:"—", isReal:false,
|
||||
// caption}` placeholders — NO fabricated numbers. `notReal()` rows are styled
|
||||
// muted by the drawer content so they never read as live.
|
||||
|
||||
const DASH = "—";
|
||||
|
||||
function notReal(caption: string): PticaField {
|
||||
return { value: DASH, isReal: false, caption };
|
||||
}
|
||||
|
||||
/** A single "Параметр → Значение" row with real/placeholder provenance. */
|
||||
export interface DrawerKvRow {
|
||||
k: string;
|
||||
field: PticaField;
|
||||
}
|
||||
|
||||
function metersField(
|
||||
m: number | null | undefined,
|
||||
caption: string,
|
||||
): PticaField {
|
||||
return m != null ? real(`${formatInt(m)} м`) : notReal(caption);
|
||||
}
|
||||
|
||||
// ── passport — full ЕГРН / НСПД (REAL) ────────────────────────────────────────
|
||||
|
||||
export interface PticaPassportDrawer {
|
||||
summary: string;
|
||||
registry: DrawerKvRow[];
|
||||
zouitCount: number | null;
|
||||
zouitOverlaps: Array<{ layer: string; name: string; sub: string }>;
|
||||
}
|
||||
|
||||
export function adaptPassportDrawer(a: ParcelAnalysis): PticaPassportDrawer {
|
||||
const p = adaptPassport(a);
|
||||
const areaHa = a.geometry_suitability?.area_ha;
|
||||
const zouit = a.nspd_zouit_overlaps ?? null;
|
||||
|
||||
const districtPart = a.district?.district_name
|
||||
? `, ${a.district.district_name} р-н`
|
||||
: "";
|
||||
const summary =
|
||||
`Участок ${a.cad_num}${districtPart}. ` +
|
||||
(a.egrn?.land_category ? `Категория — ${a.egrn.land_category}. ` : "") +
|
||||
(a.egrn?.permitted_use_text ? `ВРИ — ${a.egrn.permitted_use_text}. ` : "") +
|
||||
(a.egrn?.parcel_status ? `Статус ЕГРН — ${a.egrn.parcel_status}.` : "");
|
||||
|
||||
const registry: DrawerKvRow[] = [
|
||||
{ k: "Кадастровый номер", field: real(a.cad_num) },
|
||||
{ k: "Адрес", field: p.address },
|
||||
{ k: "Район", field: p.district },
|
||||
{ k: "Площадь", field: p.area },
|
||||
{
|
||||
k: "Площадь, га",
|
||||
field: areaHa != null ? real(formatHa(areaHa)) : notReal("нет геометрии"),
|
||||
},
|
||||
{ k: "Категория земель", field: p.landCategory },
|
||||
{ k: "ВРИ", field: p.vri },
|
||||
{ k: "Статус ЕГРН", field: p.status },
|
||||
{
|
||||
k: "Кадастровая стоимость",
|
||||
field:
|
||||
a.egrn?.cadastral_value_rub != null
|
||||
? real(`${formatInt(a.egrn.cadastral_value_rub)} ₽`)
|
||||
: notReal("нет данных ЕГРН"),
|
||||
},
|
||||
{
|
||||
k: "Стоимость, ₽/м²",
|
||||
field:
|
||||
a.egrn?.cost_per_m2_rub != null
|
||||
? real(formatRubPerM2(a.egrn.cost_per_m2_rub))
|
||||
: notReal("нет данных ЕГРН"),
|
||||
},
|
||||
{
|
||||
k: "Дата обновления ЕГРН",
|
||||
field: a.egrn?.last_egrn_update_date
|
||||
? real(a.egrn.last_egrn_update_date)
|
||||
: notReal("нет данных ЕГРН"),
|
||||
},
|
||||
{ k: "Обременения", field: { value: DASH, isReal: true } },
|
||||
];
|
||||
|
||||
const zouitOverlaps = (zouit ?? []).map((z) => ({
|
||||
layer: z.layer,
|
||||
name: z.name ?? z.type_zone ?? z.category_name ?? "ЗОУИТ",
|
||||
sub: z.subcategory != null ? String(z.subcategory) : DASH,
|
||||
}));
|
||||
|
||||
return {
|
||||
summary,
|
||||
registry,
|
||||
zouitCount: zouit != null ? zouit.length : null,
|
||||
zouitOverlaps,
|
||||
};
|
||||
}
|
||||
|
||||
// ── buildability — factor breakdown (DERIVED / предв.) ─────────────────────────
|
||||
|
||||
export interface PticaFactorBar {
|
||||
name: string;
|
||||
pct: number;
|
||||
value: string;
|
||||
tone: "good" | "bad" | "neutral";
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface PticaBuildabilityDrawer {
|
||||
gauge: PticaGauge;
|
||||
summary: string;
|
||||
factors: PticaFactorBar[];
|
||||
geometry: DrawerKvRow[];
|
||||
penalties: Array<{ label: string; impact: string }>;
|
||||
isReal: boolean;
|
||||
}
|
||||
|
||||
export function adaptBuildabilityDrawer(
|
||||
a: ParcelAnalysis,
|
||||
): PticaBuildabilityDrawer {
|
||||
const gauge = adaptBuildabilityGauge(a);
|
||||
const gate = a.gate_verdict;
|
||||
const geom = a.geometry_suitability;
|
||||
const utilCount = a.utilities?.summary?.length ?? null;
|
||||
|
||||
// Soft, derived factor contributions — labelled «предв.» everywhere.
|
||||
const factors: PticaFactorBar[] = [
|
||||
{
|
||||
name: "Гейт-вердикт",
|
||||
pct:
|
||||
gate?.can_build_mkd === true
|
||||
? 90
|
||||
: gate?.can_build_mkd === false
|
||||
? 20
|
||||
: 55,
|
||||
value: gate?.verdict_label ?? DASH,
|
||||
tone:
|
||||
gate?.can_build_mkd === true
|
||||
? "good"
|
||||
: gate?.can_build_mkd === false
|
||||
? "bad"
|
||||
: "neutral",
|
||||
reason: gate ? `источник: ${gate.source}` : "нет gate_verdict",
|
||||
},
|
||||
{
|
||||
name: "Геометрия",
|
||||
pct: geom?.suitability_score != null ? geom.suitability_score * 100 : 0,
|
||||
value:
|
||||
geom?.suitability_score != null
|
||||
? `${fmtNum(geom.suitability_score * 10, 1)} / 10`
|
||||
: DASH,
|
||||
tone: "neutral",
|
||||
reason: geom?.label ? `форма: ${geom.label}` : "нет геометрии",
|
||||
},
|
||||
{
|
||||
name: "Инженерия",
|
||||
pct: utilCount != null ? Math.min(100, utilCount * 20) : 0,
|
||||
value: utilCount != null ? `${utilCount} сетей` : DASH,
|
||||
tone: utilCount != null && utilCount >= 3 ? "good" : "neutral",
|
||||
reason: "близость инж. узлов (utilities)",
|
||||
},
|
||||
];
|
||||
|
||||
const geometry: DrawerKvRow[] = [
|
||||
{
|
||||
k: "Площадь",
|
||||
field:
|
||||
geom?.area_ha != null
|
||||
? real(`${formatHa(geom.area_ha)} · ${areaM2FromHa(geom.area_ha)}`)
|
||||
: notReal("нет геометрии"),
|
||||
},
|
||||
{
|
||||
k: "Периметр",
|
||||
field: metersField(geom?.perimeter_m, "нет геометрии"),
|
||||
},
|
||||
{
|
||||
k: "Соотношение сторон",
|
||||
field:
|
||||
geom?.aspect_ratio != null
|
||||
? real(fmtNum(geom.aspect_ratio, 2))
|
||||
: notReal("нет геометрии"),
|
||||
},
|
||||
{
|
||||
k: "Suitability score",
|
||||
field:
|
||||
geom?.suitability_score != null
|
||||
? real(`${fmtNum(geom.suitability_score * 10, 1)} / 10`)
|
||||
: notReal("нет геометрии"),
|
||||
},
|
||||
{ k: "Пятно застройки", field: notReal("нужен max_far (НСПД)") },
|
||||
{ k: "Продаваемая площадь", field: notReal("нужен max_far (НСПД)") },
|
||||
];
|
||||
|
||||
const penalties = (geom?.penalties ?? []).map((p) => ({
|
||||
label: p,
|
||||
impact: "штраф",
|
||||
}));
|
||||
|
||||
const summary =
|
||||
"Индекс застраиваемости — предварительная оценка по гейт-вердикту, геометрии " +
|
||||
"участка и близости инженерии. Это эвристика, а не итоговый индекс: точное " +
|
||||
"значение появится после расчёта потенциала (max_far НСПД) и финмодели.";
|
||||
|
||||
return {
|
||||
gauge,
|
||||
summary,
|
||||
factors,
|
||||
geometry,
|
||||
penalties,
|
||||
isReal: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ── urban — Градостроительство (mostly PLACEHOLDER) ───────────────────────────
|
||||
|
||||
export interface PticaUrbanDrawer {
|
||||
summary: string;
|
||||
regulation: DrawerKvRow[];
|
||||
zoneCode: PticaField;
|
||||
zoneName: PticaField;
|
||||
}
|
||||
|
||||
export function adaptUrbanDrawer(a: ParcelAnalysis): PticaUrbanDrawer {
|
||||
const zoning = a.nspd_zoning;
|
||||
const zoneCode = zoning?.zone_code
|
||||
? real(zoning.zone_code)
|
||||
: notReal("источник НСПД-зонирование");
|
||||
const zoneName = zoning?.zone_name
|
||||
? real(zoning.zone_name)
|
||||
: notReal("источник НСПД-зонирование");
|
||||
|
||||
const regulation: DrawerKvRow[] = [
|
||||
{ k: "Территориальная зона", field: zoneCode },
|
||||
{ k: "Наименование зоны", field: zoneName },
|
||||
{
|
||||
k: "ВРИ (основной)",
|
||||
field: a.egrn?.permitted_use_text
|
||||
? real(a.egrn.permitted_use_text)
|
||||
: notReal("нет данных ЕГРН"),
|
||||
},
|
||||
{ k: "КСИТ (max FAR)", field: notReal("нужен max_far (НСПД)") },
|
||||
{ k: "Предельная высота", field: notReal("источник НСПД-зонирование") },
|
||||
{
|
||||
k: "Коэфф. застройки (пятно)",
|
||||
field: notReal("источник НСПД-зонирование"),
|
||||
},
|
||||
{ k: "Плотность (КСИТ)", field: notReal("после расчёта потенциала") },
|
||||
];
|
||||
|
||||
const summary =
|
||||
"Градостроительный регламент участка. Территориальная зона и ВРИ берутся из " +
|
||||
"НСПД-зонирования / ЕГРН; КСИТ (max_far), предельная высота и плотность пока " +
|
||||
"недоступны в выгрузке — отмечены «—» с источником.";
|
||||
|
||||
return { summary, regulation, zoneCode, zoneName };
|
||||
}
|
||||
|
||||
// ── restrictions / legal — ЗОУИТ (REAL list) ──────────────────────────────────
|
||||
|
||||
export interface PticaRestrictionsDrawer {
|
||||
summary: string;
|
||||
zouitCount: number | null;
|
||||
zouitRows: Array<{ layer: string; sub: string; reg: string }>;
|
||||
statusRows: Array<{ label: string; state: string; tone: "ok" | "warn" }>;
|
||||
}
|
||||
|
||||
export function adaptRestrictionsDrawer(
|
||||
a: ParcelAnalysis,
|
||||
): PticaRestrictionsDrawer {
|
||||
const zouit = a.nspd_zouit_overlaps ?? null;
|
||||
const count = zouit != null ? zouit.length : null;
|
||||
|
||||
const zouitRows = (zouit ?? []).map((z) => ({
|
||||
layer: z.name ?? z.type_zone ?? z.layer,
|
||||
sub:
|
||||
z.category_name ?? (z.subcategory != null ? String(z.subcategory) : DASH),
|
||||
reg: z.group_key,
|
||||
}));
|
||||
|
||||
const statusRows: Array<{
|
||||
label: string;
|
||||
state: string;
|
||||
tone: "ok" | "warn";
|
||||
}> = [
|
||||
{
|
||||
label: "ЗОУИТ (зоны с особыми условиями)",
|
||||
state: count != null ? `${count}` : "нет данных НСПД",
|
||||
tone: count != null && count > 0 ? "warn" : "ok",
|
||||
},
|
||||
{
|
||||
label: "Красные линии",
|
||||
state:
|
||||
a.nspd_red_lines != null
|
||||
? `${a.nspd_red_lines.length}`
|
||||
: "источник НСПД",
|
||||
tone: (a.nspd_red_lines?.length ?? 0) > 0 ? "warn" : "ok",
|
||||
},
|
||||
{
|
||||
label: "ОКН (объекты культ. наследия)",
|
||||
state: "источник НСПД",
|
||||
tone: "ok",
|
||||
},
|
||||
{ label: "Сервитуты", state: "источник ЕГРН", tone: "ok" },
|
||||
];
|
||||
|
||||
const summary =
|
||||
count != null
|
||||
? `Зон с особыми условиями использования территории (ЗОУИТ): ${count}. ` +
|
||||
"Красные линии, ОКН и сервитуты требуют отдельной выгрузки НСПД / ЕГРН."
|
||||
: "Данные НСПД по ограничениям недоступны — ЗОУИТ, красные линии, ОКН и " +
|
||||
"сервитуты отмечены источником.";
|
||||
|
||||
return { summary, zouitCount: count, zouitRows, statusRows };
|
||||
}
|
||||
|
||||
export interface PticaLegalDrawer {
|
||||
summary: string;
|
||||
gate: DrawerKvRow[];
|
||||
restrictions: Array<{ label: string; state: string; tone: "ok" | "warn" }>;
|
||||
registry: DrawerKvRow[];
|
||||
}
|
||||
|
||||
export function adaptLegalDrawer(a: ParcelAnalysis): PticaLegalDrawer {
|
||||
const gate = a.gate_verdict;
|
||||
const restr = adaptRestrictionsDrawer(a);
|
||||
|
||||
const gateRows: DrawerKvRow[] = [
|
||||
{
|
||||
k: "Можно строить МКД",
|
||||
field: gate ? real(gate.verdict_label) : notReal("нет gate_verdict"),
|
||||
},
|
||||
{
|
||||
k: "Статус участка",
|
||||
field: a.egrn?.parcel_status
|
||||
? real(a.egrn.parcel_status)
|
||||
: notReal("нет данных ЕГРН"),
|
||||
},
|
||||
{
|
||||
k: "Категория земель",
|
||||
field: a.egrn?.land_category
|
||||
? real(a.egrn.land_category)
|
||||
: notReal("нет данных ЕГРН"),
|
||||
},
|
||||
{
|
||||
k: "ВРИ",
|
||||
field: a.egrn?.permitted_use_text
|
||||
? real(a.egrn.permitted_use_text)
|
||||
: notReal("нет данных ЕГРН"),
|
||||
},
|
||||
{
|
||||
k: "Зона ПЗЗ",
|
||||
field: a.nspd_zoning?.zone_code
|
||||
? real(a.nspd_zoning.zone_code)
|
||||
: notReal("источник НСПД"),
|
||||
},
|
||||
];
|
||||
|
||||
const registry: DrawerKvRow[] = [
|
||||
{
|
||||
k: "ЗОУИТ",
|
||||
field:
|
||||
restr.zouitCount != null
|
||||
? real(formatInt(restr.zouitCount))
|
||||
: notReal("источник НСПД"),
|
||||
},
|
||||
{ k: "Обременения", field: { value: DASH, isReal: true } },
|
||||
{
|
||||
k: "Блокеры (gate)",
|
||||
field: gate
|
||||
? real(formatInt(gate.blockers.length))
|
||||
: notReal("нет gate_verdict"),
|
||||
},
|
||||
{
|
||||
k: "Предупреждения (gate)",
|
||||
field: gate
|
||||
? real(formatInt(gate.warnings.length))
|
||||
: notReal("нет gate_verdict"),
|
||||
},
|
||||
];
|
||||
|
||||
const summary =
|
||||
"Правовой статус: гейт-вердикт о допустимости МКД (НСПД-дамп) + реестр " +
|
||||
"ограничений. Обременения в текущей выгрузке не раскрываются («—»).";
|
||||
|
||||
return { summary, gate: gateRows, restrictions: restr.statusRows, registry };
|
||||
}
|
||||
|
||||
// ── engineering — utilities.summary (REAL) ────────────────────────────────────
|
||||
|
||||
export interface PticaEngineeringDrawer {
|
||||
summary: string;
|
||||
rows: Array<{ label: string; nearest: string; name: string; count: string }>;
|
||||
bars: PticaFactorBar[];
|
||||
hasData: boolean;
|
||||
}
|
||||
|
||||
export function adaptEngineeringDrawer(
|
||||
a: ParcelAnalysis,
|
||||
): PticaEngineeringDrawer {
|
||||
const summaryArr = a.utilities?.summary ?? [];
|
||||
const hasData = summaryArr.length > 0;
|
||||
|
||||
const rows = summaryArr.map((u) => ({
|
||||
label: utilityLabel(u.subtype),
|
||||
nearest: `${formatInt(u.nearest_m)} м`,
|
||||
name: u.name ?? DASH,
|
||||
count: u.count_within_2km != null ? formatInt(u.count_within_2km) : DASH,
|
||||
}));
|
||||
|
||||
// bar = inverse distance (closer → fuller). Cap reference 2000 m.
|
||||
const bars: PticaFactorBar[] = summaryArr.map((u) => ({
|
||||
name: utilityLabel(u.subtype),
|
||||
pct: Math.max(4, 100 - Math.min(100, (u.nearest_m / 2000) * 100)),
|
||||
value: `${formatInt(u.nearest_m)} м`,
|
||||
tone: u.nearest_m <= 700 ? "good" : "neutral",
|
||||
reason: u.name ?? "",
|
||||
}));
|
||||
|
||||
const summary = hasData
|
||||
? `Инженерная обеспеченность: ${summaryArr.length} типов сетей в радиусе ` +
|
||||
"анализа. Расстояния — до ближайшего узла каждого ресурса (НСПД)."
|
||||
: "Данные по инженерным сетям недоступны для участка.";
|
||||
|
||||
return { summary, rows, bars, hasData };
|
||||
}
|
||||
|
||||
// ── market — 4 sub-tabs (comp REAL · plan/speed/trend mixed) ───────────────────
|
||||
|
||||
export interface PticaMarketCompetitor {
|
||||
name: string;
|
||||
dev: string;
|
||||
cls: string;
|
||||
flats: string;
|
||||
distance: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface PticaMarketDrawer {
|
||||
summary: string;
|
||||
competitors: PticaMarketCompetitor[];
|
||||
medianField: PticaField;
|
||||
trend: {
|
||||
field: PticaField;
|
||||
recent: string;
|
||||
prior: string;
|
||||
count: string;
|
||||
available: boolean;
|
||||
};
|
||||
speed: {
|
||||
field: PticaField;
|
||||
velocityScore: string;
|
||||
monthly: string;
|
||||
median: string;
|
||||
confidence: string;
|
||||
available: boolean;
|
||||
};
|
||||
plan: {
|
||||
available: boolean;
|
||||
note: string;
|
||||
pipelineByClass: Array<{ cls: string; count: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
function competitorRow(c: ParcelAnalysisCompetitor): PticaMarketCompetitor {
|
||||
return {
|
||||
name: c.comm_name ?? `#${c.obj_id}`,
|
||||
dev: c.dev_name ?? DASH,
|
||||
cls: c.obj_class ?? DASH,
|
||||
flats: c.flat_count != null ? formatInt(c.flat_count) : DASH,
|
||||
distance: `${formatInt(c.distance_m)} м`,
|
||||
status: c.site_status ?? DASH,
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptMarketDrawer(a: ParcelAnalysis): PticaMarketDrawer {
|
||||
const competitors = (a.competitors ?? []).slice(0, 12).map(competitorRow);
|
||||
const median = a.district?.median_price_per_m2;
|
||||
const medianField =
|
||||
median != null
|
||||
? real(formatRubPerM2(median))
|
||||
: notReal("нет данных района");
|
||||
|
||||
const mt = a.market_trend;
|
||||
const trend = {
|
||||
field:
|
||||
mt != null
|
||||
? real(
|
||||
`${mt.delta_6m_pct >= 0 ? "+" : ""}${fmtNum(mt.delta_6m_pct, 1)}%`,
|
||||
)
|
||||
: notReal("после прогноза"),
|
||||
recent: mt != null ? formatRubPerM2(mt.recent_avg_price_per_m2) : DASH,
|
||||
prior: mt != null ? formatRubPerM2(mt.prior_avg_price_per_m2) : DASH,
|
||||
count: mt != null ? `${formatInt(mt.recent_deals_count)} сделок` : DASH,
|
||||
available: mt != null,
|
||||
};
|
||||
|
||||
const v = a.velocity ?? null;
|
||||
const speed = {
|
||||
field:
|
||||
v != null
|
||||
? real(`${fmtNum(v.velocity_score * 10, 1)} / 10`)
|
||||
: notReal("после прогноза"),
|
||||
velocityScore: v != null ? fmtNum(v.velocity_score * 10, 1) : DASH,
|
||||
monthly: v != null ? `${formatInt(v.monthly_velocity_sqm)} м²/мес` : DASH,
|
||||
median: v != null ? `${formatInt(v.ekb_median_sqm)} м²/мес` : DASH,
|
||||
confidence: v != null ? v.confidence : DASH,
|
||||
available: v != null,
|
||||
};
|
||||
|
||||
const pipeline = a.pipeline_24mo ?? null;
|
||||
const pipelineByClass = pipeline
|
||||
? Object.entries(pipeline.by_class).map(([cls, count]) => ({ cls, count }))
|
||||
: [];
|
||||
const plan = {
|
||||
available: pipeline != null,
|
||||
note:
|
||||
pipeline != null
|
||||
? `Пайплайн 24 мес: ${pipeline.objects_count} проектов, ` +
|
||||
`${formatInt(pipeline.flats_total)} квартир.`
|
||||
: "Планировки и квартирография — после прогноза (§22).",
|
||||
pipelineByClass,
|
||||
};
|
||||
|
||||
const summary =
|
||||
`Конкурентов в радиусе анализа: ${competitors.length}` +
|
||||
(median != null ? `, медиана ${formatRubPerM2(median)}` : "") +
|
||||
(pipeline != null
|
||||
? `, ${pipeline.objects_count} проектов в пайплайне`
|
||||
: "") +
|
||||
". Тренд, скорость и планировки уточняются прогнозом §22.";
|
||||
|
||||
return { summary, competitors, medianField, trend, speed, plan };
|
||||
}
|
||||
|
||||
// ── environment (Среда) — noise / AQI / wind / weather (REAL) ──────────────────
|
||||
|
||||
export interface PticaEnvironmentDrawer {
|
||||
summary: string;
|
||||
noise: {
|
||||
rows: DrawerKvRow[];
|
||||
sources: Array<{ name: string; db: string; dist: string }>;
|
||||
available: boolean;
|
||||
};
|
||||
air: { rows: DrawerKvRow[]; available: boolean };
|
||||
wind: { rows: DrawerKvRow[]; available: boolean };
|
||||
weather: { rows: DrawerKvRow[]; available: boolean };
|
||||
}
|
||||
|
||||
const WIND_DIR_NOTE = "источник: прогноз ветра";
|
||||
|
||||
export function adaptEnvironmentDrawer(
|
||||
a: ParcelAnalysis,
|
||||
): PticaEnvironmentDrawer {
|
||||
const n = a.noise;
|
||||
const aq = a.air_quality;
|
||||
const w = a.wind;
|
||||
const wt = a.weather;
|
||||
|
||||
const noiseRows: DrawerKvRow[] = n
|
||||
? [
|
||||
{ k: "Уровень", field: real(n.level) },
|
||||
{ k: "Оценка шума", field: real(`${formatInt(n.estimated_db)} дБ`) },
|
||||
{ k: "Скор", field: real(fmtNum(n.score, 2)) },
|
||||
{ k: "Источников шума", field: real(formatInt(n.sources.length)) },
|
||||
]
|
||||
: [{ k: "Шум", field: notReal("нет данных шума") }];
|
||||
|
||||
const airRows: DrawerKvRow[] = aq
|
||||
? [
|
||||
{ k: "PM2.5", field: real(`${fmtNum(aq.pm2_5, 1)} мкг/м³`) },
|
||||
{ k: "PM10", field: real(`${fmtNum(aq.pm10, 1)} мкг/м³`) },
|
||||
{ k: "NO₂", field: real(`${fmtNum(aq.no2, 1)} мкг/м³`) },
|
||||
{ k: "Источник", field: real(aq.source) },
|
||||
]
|
||||
: [{ k: "Воздух", field: notReal("нет данных AQI") }];
|
||||
|
||||
const windRows: DrawerKvRow[] = w
|
||||
? [
|
||||
{
|
||||
k: "Направление",
|
||||
field: real(
|
||||
`${w.dominant_direction_label} (${formatInt(w.dominant_direction_deg)}°)`,
|
||||
),
|
||||
},
|
||||
{
|
||||
k: "Макс. скорость",
|
||||
field:
|
||||
w.max_speed_m_s != null
|
||||
? real(`${fmtNum(w.max_speed_m_s, 1)} м/с`)
|
||||
: notReal(WIND_DIR_NOTE),
|
||||
},
|
||||
{
|
||||
k: "Горизонт прогноза",
|
||||
field: real(`${formatInt(w.forecast_days)} дн.`),
|
||||
},
|
||||
]
|
||||
: [{ k: "Ветер", field: notReal("нет данных ветра") }];
|
||||
|
||||
const weatherRows: DrawerKvRow[] = wt
|
||||
? [
|
||||
{
|
||||
k: "Ср. макс. °C",
|
||||
field:
|
||||
wt.temperature.avg_max_c != null
|
||||
? real(`${fmtNum(wt.temperature.avg_max_c, 1)} °C`)
|
||||
: notReal("нет данных погоды"),
|
||||
},
|
||||
{
|
||||
k: "Ср. мин. °C",
|
||||
field:
|
||||
wt.temperature.avg_min_c != null
|
||||
? real(`${fmtNum(wt.temperature.avg_min_c, 1)} °C`)
|
||||
: notReal("нет данных погоды"),
|
||||
},
|
||||
{
|
||||
k: "Осадки (сумма)",
|
||||
field: real(`${formatInt(wt.precipitation_total_mm)} мм`),
|
||||
},
|
||||
{ k: "Дней с осадками", field: real(formatInt(wt.precipitation_days)) },
|
||||
]
|
||||
: [{ k: "Погода", field: notReal("нет данных погоды") }];
|
||||
|
||||
const sources = (n?.sources ?? []).map((s) => ({
|
||||
name: s.name ?? s.source_type,
|
||||
db: `${formatInt(s.estimated_db)} дБ`,
|
||||
dist: `${formatInt(s.distance_m)} м`,
|
||||
}));
|
||||
|
||||
const summary =
|
||||
"Средовые факторы участка: шум, качество воздуха, преобладающий ветер и " +
|
||||
"погодный фон. Данные из атмосферного слоя /analyze.";
|
||||
|
||||
return {
|
||||
summary,
|
||||
noise: { rows: noiseRows, sources, available: n != null },
|
||||
air: { rows: airRows, available: aq != null },
|
||||
wind: { rows: windRows, available: w != null },
|
||||
weather: { rows: weatherRows, available: wt != null },
|
||||
};
|
||||
}
|
||||
|
||||
// ── georisks — geology / hydrology / geotech (REAL where present) ──────────────
|
||||
|
||||
export interface PticaGeorisksDrawer {
|
||||
summary: string;
|
||||
geology: {
|
||||
rows: DrawerKvRow[];
|
||||
links: Array<{ label: string; href: string }>;
|
||||
available: boolean;
|
||||
};
|
||||
hydrology: { rows: DrawerKvRow[]; available: boolean };
|
||||
geotech: { rows: DrawerKvRow[]; available: boolean };
|
||||
}
|
||||
|
||||
export function adaptGeorisksDrawer(a: ParcelAnalysis): PticaGeorisksDrawer {
|
||||
const g = a.geology;
|
||||
const h = a.hydrology;
|
||||
const gt = a.geotech_risk;
|
||||
|
||||
const geologyRows: DrawerKvRow[] = g
|
||||
? [
|
||||
{
|
||||
k: "Данные геологии",
|
||||
field: g.data_available
|
||||
? real("доступны")
|
||||
: notReal("нет ИГИ — нужен полевой расчёт"),
|
||||
},
|
||||
{ k: "Примечание", field: g.note ? real(g.note) : notReal("—") },
|
||||
]
|
||||
: [{ k: "Геология", field: notReal("нет данных геологии") }];
|
||||
|
||||
const geologyLinks = g
|
||||
? [
|
||||
{ label: "Карпинский WebGIS", href: g.links.karpinsky_webgis },
|
||||
{ label: "ЕФГИ (фед. реестр)", href: g.links.efgi_federal_registry },
|
||||
]
|
||||
: [];
|
||||
|
||||
const hydrologyRows: DrawerKvRow[] = h
|
||||
? [
|
||||
{
|
||||
k: "Риск подтопления",
|
||||
field: real(h.flood_risk_flag ? "есть" : "нет"),
|
||||
},
|
||||
{
|
||||
k: "Ближайший водоём",
|
||||
field:
|
||||
h.nearest.length > 0
|
||||
? real(`${formatInt(h.nearest[0].distance_m)} м`)
|
||||
: notReal("нет данных"),
|
||||
},
|
||||
{ k: "Примечание", field: h.note ? real(h.note) : notReal("—") },
|
||||
]
|
||||
: [{ k: "Гидрология", field: notReal("нет данных гидрологии") }];
|
||||
|
||||
const geotechRows: DrawerKvRow[] = gt
|
||||
? [
|
||||
{ k: "Сейсмика", field: real(gt.seismic_label) },
|
||||
{
|
||||
k: "Баллы (сейсм.)",
|
||||
field:
|
||||
gt.seismic_intensity_balls != null
|
||||
? real(formatInt(gt.seismic_intensity_balls))
|
||||
: notReal("нет данных"),
|
||||
},
|
||||
{
|
||||
k: "Многолетняя мерзлота",
|
||||
field: real(gt.permafrost ? "есть" : "нет"),
|
||||
},
|
||||
{
|
||||
k: "Пром. объектов <500 м",
|
||||
field: real(formatInt(gt.industrial_within_500m)),
|
||||
},
|
||||
]
|
||||
: [{ k: "Геотехника", field: notReal("нет данных геотехники") }];
|
||||
|
||||
const summary =
|
||||
"Инженерно-геологический фон участка: геология, гидрология и геотехнические " +
|
||||
"риски. Поля без полевых данных отмечены «—» (нужны ИГИ).";
|
||||
|
||||
return {
|
||||
summary,
|
||||
geology: { rows: geologyRows, links: geologyLinks, available: g != null },
|
||||
hydrology: { rows: hydrologyRows, available: h != null },
|
||||
geotech: { rows: geotechRows, available: gt != null },
|
||||
};
|
||||
}
|
||||
|
||||
// ── risks — composite breakdown (DERIVED / предв.) ─────────────────────────────
|
||||
|
||||
export interface PticaRisksDrawer {
|
||||
gauge: PticaGauge;
|
||||
summary: string;
|
||||
breakdown: PticaFactorBar[];
|
||||
blockers: Array<{ code: string; detail: string }>;
|
||||
warnings: Array<{ code: string; detail: string }>;
|
||||
isReal: boolean;
|
||||
}
|
||||
|
||||
export function adaptRisksDrawer(a: ParcelAnalysis): PticaRisksDrawer {
|
||||
const gauge = adaptRiskGauge(a);
|
||||
const gate = a.gate_verdict;
|
||||
const geom = a.geometry_suitability;
|
||||
const n = a.noise;
|
||||
|
||||
const breakdown: PticaFactorBar[] = [
|
||||
{
|
||||
name: "Правовые / гейт",
|
||||
pct: gate
|
||||
? Math.min(100, gate.blockers.length * 30 + gate.warnings.length * 12)
|
||||
: 0,
|
||||
value: gate
|
||||
? `${gate.blockers.length} блок. · ${gate.warnings.length} пред.`
|
||||
: DASH,
|
||||
tone: (gate?.blockers.length ?? 0) > 0 ? "bad" : "good",
|
||||
reason: gate ? "из gate_verdict" : "нет gate_verdict",
|
||||
},
|
||||
{
|
||||
name: "Геометрия",
|
||||
pct:
|
||||
geom?.suitability_score != null
|
||||
? (1 - geom.suitability_score) * 100
|
||||
: 0,
|
||||
value:
|
||||
geom?.suitability_score != null
|
||||
? `${fmtNum((1 - geom.suitability_score) * 10, 1)} / 10`
|
||||
: DASH,
|
||||
tone: "neutral",
|
||||
reason: geom?.label ? geom.label : "нет геометрии",
|
||||
},
|
||||
{
|
||||
name: "Шум / среда",
|
||||
pct: n != null ? Math.min(100, (n.estimated_db / 80) * 100) : 0,
|
||||
value: n != null ? `${formatInt(n.estimated_db)} дБ · ${n.level}` : DASH,
|
||||
tone: n != null && n.estimated_db >= 60 ? "bad" : "neutral",
|
||||
reason: n != null ? "из шумового слоя" : "нет данных шума",
|
||||
},
|
||||
];
|
||||
|
||||
const summary =
|
||||
"Сводный риск — предварительная композиция из гейт-вердикта, геометрии и " +
|
||||
"средовых факторов. Это эвристика («предв.»), а не итоговая риск-модель.";
|
||||
|
||||
return {
|
||||
gauge,
|
||||
summary,
|
||||
breakdown,
|
||||
blockers: gate?.blockers ?? [],
|
||||
warnings: gate?.warnings ?? [],
|
||||
isReal: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ── product — §22 product_tz («после прогноза» while absent) ───────────────────
|
||||
|
||||
export interface PticaProductDrawer {
|
||||
available: boolean;
|
||||
summary: string;
|
||||
objClass: PticaField;
|
||||
mix: Array<{ bucket: string; cls: string; signal: string; pct: number }>;
|
||||
usp: Array<{ text: string; cls: string }>;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
function mixBarPct(entry: ProductMixEntry, total: number): number {
|
||||
if (entry.pct != null) return entry.pct;
|
||||
// No explicit share → even split as a visual hint only.
|
||||
return total > 0 ? Math.round(100 / total) : 0;
|
||||
}
|
||||
|
||||
export function adaptProductDrawer(
|
||||
report?: ForecastReport,
|
||||
): PticaProductDrawer {
|
||||
const tz = report?.product_tz;
|
||||
if (!tz) {
|
||||
return {
|
||||
available: false,
|
||||
summary:
|
||||
"Продуктовая стратегия (класс, квартирография, USP) формируется в " +
|
||||
"асинхронном прогнозе §22. Откройте вкладку «Сценарии», чтобы запустить расчёт.",
|
||||
objClass: notReal("после прогноза (Scenarios)"),
|
||||
mix: [],
|
||||
usp: [],
|
||||
reasons: [],
|
||||
};
|
||||
}
|
||||
|
||||
const mix = tz.mix.map((m) => ({
|
||||
bucket: m.bucket ?? DASH,
|
||||
cls: m.obj_class ?? DASH,
|
||||
signal: m.signal ?? DASH,
|
||||
pct: mixBarPct(m, tz.mix.length),
|
||||
}));
|
||||
|
||||
const usp = tz.usp.map((u) => ({
|
||||
text: u.usp_text ?? DASH,
|
||||
cls: u.obj_class ?? DASH,
|
||||
}));
|
||||
|
||||
const reasons = tz.reasons
|
||||
.map((r) => r.why)
|
||||
.filter((w): w is string => Boolean(w));
|
||||
|
||||
return {
|
||||
available: true,
|
||||
summary: tz.summary ?? "Рекомендация продукта из прогноза §22 (advisory).",
|
||||
objClass: tz.obj_class
|
||||
? { value: tz.obj_class, isReal: true, caption: "advisory · §22" }
|
||||
: notReal("нет рекомендации класса"),
|
||||
mix,
|
||||
usp,
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
|
||||
// ── potential — ёмкость (PLACEHOLDER — нужен max_far) ──────────────────────────
|
||||
|
||||
export interface PticaPotentialDrawer {
|
||||
summary: string;
|
||||
rows: DrawerKvRow[];
|
||||
}
|
||||
|
||||
export function adaptPotentialDrawer(a: ParcelAnalysis): PticaPotentialDrawer {
|
||||
const areaHa = a.geometry_suitability?.area_ha;
|
||||
const areaField =
|
||||
areaHa != null
|
||||
? real(`${areaM2FromHa(areaHa)} (${formatHa(areaHa)})`)
|
||||
: notReal("нет геометрии");
|
||||
|
||||
const rows: DrawerKvRow[] = [
|
||||
{ k: "Площадь участка", field: areaField },
|
||||
{ k: "КСИТ (max_far)", field: notReal("нужен max_far (НСПД)") },
|
||||
{ k: "Ёмкость по КСИТ", field: notReal("нужен max_far (НСПД)") },
|
||||
{ k: "Пятно застройки", field: notReal("нужен max_far (НСПД)") },
|
||||
{ k: "Продаваемая площадь", field: notReal("после расчёта потенциала") },
|
||||
{
|
||||
k: "Расчётная этажность",
|
||||
field: notReal("нужна предельная высота (НСПД)"),
|
||||
},
|
||||
];
|
||||
|
||||
const summary =
|
||||
"Расчёт ёмкости (плотность, пятно, продаваемая площадь) требует предельного " +
|
||||
"КСИТ (max_far) и высоты из НСПД-зонирования. Доступна только площадь участка.";
|
||||
|
||||
return { summary, rows };
|
||||
}
|
||||
|
||||
// ── economy / finance — PLACEHOLDER (после финмодели) ──────────────────────────
|
||||
|
||||
export interface PticaEconomyDrawer {
|
||||
summary: string;
|
||||
rows: DrawerKvRow[];
|
||||
}
|
||||
|
||||
export function adaptEconomyDrawer(): PticaEconomyDrawer {
|
||||
return {
|
||||
summary:
|
||||
"Экономика проекта (выручка, маржа, ROI, IRR) рассчитывается после " +
|
||||
"финансовой модели (§22). Пока показатели недоступны.",
|
||||
rows: [
|
||||
{ k: "Потенц. выручка (GDV)", field: notReal("после финмодели") },
|
||||
{ k: "Себестоимость", field: notReal("после финмодели") },
|
||||
{ k: "Маржа", field: notReal("после финмодели") },
|
||||
{ k: "ROI", field: notReal("после финмодели") },
|
||||
{ k: "IRR", field: notReal("после финмодели") },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptFinanceDrawer(): PticaEconomyDrawer {
|
||||
return {
|
||||
summary:
|
||||
"Финансовая модель (P&L, cash-flow по фазам, сценарный анализ) формируется " +
|
||||
"в асинхронном расчёте §22. Откройте «Сценарии», чтобы запустить прогноз.",
|
||||
rows: [
|
||||
{ k: "GDV (выручка)", field: notReal("после финмодели") },
|
||||
{ k: "Совокупные затраты", field: notReal("после финмодели") },
|
||||
{ k: "Чистая прибыль", field: notReal("после финмодели") },
|
||||
{ k: "ROI / IRR", field: notReal("после финмодели") },
|
||||
{ k: "Срок проекта", field: notReal("после финмодели") },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ── oks — Нет данных ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface PticaOksDrawer {
|
||||
summary: string;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export function adaptOksDrawer(): PticaOksDrawer {
|
||||
return {
|
||||
summary:
|
||||
"Объекты капитального строительства на участке в текущей выгрузке /analyze " +
|
||||
"не раскрываются. Раздел появится после интеграции реестра ОКС (ЕГРН).",
|
||||
available: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Map geometry ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** EKB fallback center (lat, lon). */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue