feat(site-finder): Site Finder v2 cockpit route (ПТИЦА v2) #1866

Merged
bot-backend merged 1 commit from feat/site-finder-v2-cockpit into main 2026-06-21 17:32:38 +00:00
20 changed files with 4404 additions and 1 deletions

View file

@ -123,11 +123,24 @@ export function AnalysisPageContent({ cad }: Props) {
<div
style={{
display: "flex",
justifyContent: "flex-end",
justifyContent: "space-between",
alignItems: "center",
marginTop: 16,
gap: 12,
}}
>
{/* Non-destructive entry into the new cockpit interface (Site Finder v2). */}
<Link
href={`/site-finder/analysis/${encodeURIComponent(cad)}/v2`}
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--accent, #1D4ED8)",
textDecoration: "none",
}}
>
Site Finder v2 (beta)
</Link>
<HorizonSelector
value={horizon}
onChange={handleHorizonChange}

View file

@ -0,0 +1,70 @@
"use client";
/**
* V2PageContent client root of the Site Finder v2 cockpit. Wires the SAME
* /analyze data as the existing analysis page via useParcelAnalyzeQuery, narrows
* to ParcelAnalysis (the sanctioned `as unknown as` cast see
* AnalysisPageContent.tsx:111), polls the async §22 forecast, and renders the
* composed <V2Cockpit>.
*
* Loading / error states are themed in the scoped v2 module so they never leak
* cockpit styles to the rest of the app.
*/
import Link from "next/link";
import styles from "./v2.module.css";
import {
useParcelAnalyzeQuery,
useParcelForecastQuery,
} from "@/lib/site-finder-api";
import type { ParcelAnalysis } from "@/types/site-finder";
import { V2Cockpit } from "@/components/site-finder/ptica-v2/V2Cockpit";
interface Props {
cad: string;
}
export function V2PageContent({ cad }: Props) {
// Horizon fixed at the default 12 mo — the cockpit is a single analysis view
// (the horizon selector lives on the heavy analysis / Scenarios page).
const { data, isLoading, error } = useParcelAnalyzeQuery(cad, 12);
const { data: forecastData } = useParcelForecastQuery(cad);
const forecastReport =
forecastData?.status === "ready" ? forecastData.report : undefined;
if (isLoading) {
return (
<div className={styles.v2Root} data-theme="dark">
<div className={styles.app}>
<div className={styles.stateScreen}>
<div className={styles.stateBox}>Анализируем участок {cad}</div>
</div>
</div>
</div>
);
}
if (error || !data) {
const msg =
error instanceof Error ? error.message : "Ошибка загрузки анализа";
return (
<div className={styles.v2Root} data-theme="dark">
<div className={styles.app}>
<div className={styles.stateScreen}>
<div>
<div className={styles.stateError}>{msg}</div>
<Link href="/site-finder" className={styles.stateLink}>
Вернуться к Site Finder
</Link>
</div>
</div>
</div>
</div>
);
}
const analysis = data as unknown as ParcelAnalysis;
return <V2Cockpit analysis={analysis} forecastReport={forecastReport} />;
}

View file

@ -0,0 +1,56 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { V2PageContent } from "./V2PageContent";
// ── Metadata ──────────────────────────────────────────────────────────────────
interface PageProps {
params: Promise<{ cad: string }>;
}
// Next.js delivers `cad` URL-encoded (":" → "%3A"); decode once at the page
// boundary so downstream hooks/components see the canonical cad and re-encode
// exactly once when building API URLs (mirrors the sibling analysis page.tsx).
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { cad: cadRaw } = await params;
const cad = decodeURIComponent(cadRaw);
return {
title: `ПТИЦА v2 · ${cad}`,
description: `Кокпит анализа участка ${cad} — Site Finder v2`,
};
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default async function SiteFinderV2Page({ params }: PageProps) {
const { cad: cadRaw } = await params;
const cad = decodeURIComponent(cadRaw);
return (
<Suspense
fallback={
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#060f16",
color: "#5f7886",
fontFamily: "var(--font-plex-mono), monospace",
fontSize: 11,
letterSpacing: "0.1em",
textTransform: "uppercase",
}}
>
Загрузка кокпита
</div>
}
>
<V2PageContent cad={cad} />
</Suspense>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,201 @@
"use client";
/**
* V2BottomGrid row 4 (prototype `.row4`): Investment Clearance (финмодель) ·
* Legal Status · Site Verdict. Финмодель bignums are honest «» (после финмодели
* §23); the buy-signal gauge reads the §22 deficit_index when ready. Legal Status
* surfaces REAL ЗОУИТ / красные линии / gate, «» for the rest. Site Verdict is
* the JTBD plaque + checklist derived from the gate verdict + real signals.
*/
import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
import type { ParcelAnalysis } from "@/types/site-finder";
import type { ForecastReport } from "@/types/forecast";
import {
adaptInvestmentClearance,
adaptBuySignalGauge,
adaptLegalStatus,
adaptSiteVerdict,
} from "@/components/site-finder/ptica/ptica-adapt";
import type {
PticaSiteVerdict,
PticaVerdictCheckItem,
} from "@/components/site-finder/ptica/ptica-adapt";
import { V2Gauge } from "@/components/site-finder/ptica-v2/V2Gauge";
import { V2CheckIcon, V2WarnIcon } from "@/components/site-finder/ptica-v2/V2Icons";
import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry";
const VERDICT_CLASS: Record<PticaSiteVerdict["tone"], string> = {
good: "",
warn: styles.warn,
bad: styles.bad,
};
function CheckRow({ item }: { item: PticaVerdictCheckItem }) {
const cls =
item.tone === "ok"
? styles.okRow
: item.tone === "warn"
? styles.warnRow
: styles.badRow;
return (
<div className={`${styles.vchk} ${cls}`}>
{item.tone === "ok" ? <V2CheckIcon /> : <V2WarnIcon />}
<span>{item.text}</span>
</div>
);
}
interface Props {
analysis: ParcelAnalysis;
forecastReport?: ForecastReport;
onOpenDrawer: (key: V2DrawerKey) => void;
}
export function V2BottomGrid({ analysis, forecastReport, onOpenDrawer }: Props) {
const clearance = adaptInvestmentClearance();
const buy = adaptBuySignalGauge(forecastReport);
const legal = adaptLegalStatus(analysis);
const verdict = adaptSiteVerdict(analysis, forecastReport);
const buyColor =
buy.gauge.value == null
? "var(--text-soft)"
: buy.gauge.tone === "good"
? "var(--accent-green)"
: buy.gauge.tone === "bad"
? "var(--accent-red)"
: "var(--accent-yellow)";
return (
<section className={styles.row4}>
{/* Investment Clearance */}
<div className={`${styles.card} ${styles.finCard} ${styles.brk}`}>
<span className={styles.bc1} />
<span className={styles.bc2} />
<div className={styles.cardH}>
<div className={styles.ttl}>
<span className={styles.sectionTitle}>Investment Clearance</span>
<span className={styles.sectionSub}>финансовая модель</span>
</div>
<button
type="button"
className={styles.more}
onClick={() => onOpenDrawer("economics")}
>
ПОДРОБНЕЕ
</button>
</div>
<div className={styles.cardB}>
<div className={styles.finMain}>
{clearance.bignums
.filter((b) => b.unit)
.map((b) => (
<div key={b.label} className={styles.finBig}>
<div className={styles.k}>{b.label}</div>
<div
className={`${styles.v} ${b.isReal ? "" : styles.dashed}`}
title={b.isReal ? undefined : b.caption}
>
{b.value}
</div>
<div className={styles.u}>{b.unit}</div>
</div>
))}
<div className={styles.finSep} />
<div className={styles.finRatios}>
{clearance.bignums
.filter((b) => !b.unit)
.map((b) => (
<div key={b.label} className={styles.r}>
<div className={styles.k}>{b.label}</div>
<div
className={`${styles.v} ${b.isReal ? "" : styles.dashed}`}
title={b.isReal ? undefined : b.caption}
>
{b.value}
</div>
</div>
))}
</div>
</div>
<div className={styles.buyGauge}>
<div className={styles.lbl} style={{ marginBottom: 3 }}>
Buy signal
</div>
<div className={styles.minigauge} style={{ width: 66, height: 66 }}>
<V2Gauge gauge={buy.gauge} size="buy" />
<div className={styles.c}>
<span className={styles.p} style={{ color: buyColor, fontSize: 16 }}>
{buy.gauge.value == null ? "—" : `${buy.gauge.value}%`}
</span>
</div>
</div>
<div
className={styles.note}
style={{ color: buyColor, letterSpacing: ".06em" }}
>
{buy.word}
</div>
</div>
</div>
</div>
{/* Legal Status */}
<div className={styles.card}>
<div className={styles.cardH}>
<div className={styles.ttl}>
<span className={styles.sectionTitle}>Legal Status</span>
</div>
</div>
<div className={styles.cardB} style={{ display: "flex", flexDirection: "column" }}>
<div className={styles.legalGrid}>
{legal.rows.map((r) => (
<div key={r.k} className={styles.legalRow}>
<span className={styles.k}>{r.k}</span>
<span
className={`${styles.v} ${r.field.isReal ? "" : styles.dash}`}
title={r.field.isReal ? undefined : r.field.caption}
>
{r.field.value}
</span>
</div>
))}
</div>
<div className={styles.moreRowAuto}>
<button
type="button"
className={styles.more}
onClick={() => onOpenDrawer("restrictions")}
>
ПОДРОБНЕЕ
</button>
</div>
</div>
</div>
{/* Site Verdict */}
<div className={`${styles.card} ${styles.verdictCard} ${styles.brk}`}>
<span className={styles.bc1} />
<span className={styles.bc2} />
<div className={styles.cardH}>
<div className={styles.ttl}>
<span className={styles.sectionTitle}>Site Verdict</span>
</div>
</div>
<div className={styles.cardB}>
<div className={styles.verdictBig}>
<div className={`${styles.v} ${VERDICT_CLASS[verdict.tone]}`}>
{verdict.word}
</div>
</div>
<div className={styles.verdictCheck}>
{verdict.checklist.map((item, i) => (
<CheckRow key={`${item.text}-${i}`} item={item} />
))}
</div>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,224 @@
"use client";
/**
* V2Chat floating chat panel (prototype `.chat-panel`). Grounded, local
* keyword answerer over the SAME ptica-adapt view-models the cockpit renders, so
* the answers never invent numbers: площадь/регламент/рынок/инженерия/риски are
* read straight from the adapters (REAL or honest «»). No HTTP this mirrors
* the prototype's grounded chat; the full LLM chat lives on the heavy analysis
* page.
*/
import { useMemo, useState } from "react";
import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
import type { ParcelAnalysis } from "@/types/site-finder";
import type { ForecastReport } from "@/types/forecast";
import {
adaptPassport,
adaptUrbanCard,
adaptMarketCard,
adaptEngineeringCard,
adaptRiskGauge,
adaptBuildabilityGauge,
adaptInvestScore,
} from "@/components/site-finder/ptica/ptica-adapt";
interface ChatMsg {
text: string;
who: "bot" | "user";
}
const CHIPS = [
"Какая площадь?",
"Можно ли строить?",
"Какая цена в районе?",
"Какие риски?",
];
function joinReal(label: string, value: string, isReal: boolean): string {
return isReal ? `${label}: ${value}.` : `${label}: данных пока нет (—).`;
}
interface Props {
analysis: ParcelAnalysis;
forecastReport?: ForecastReport;
open: boolean;
onClose: () => void;
}
export function V2Chat({ analysis, forecastReport, open, onClose }: Props) {
const passport = useMemo(() => adaptPassport(analysis), [analysis]);
const urban = useMemo(() => adaptUrbanCard(analysis), [analysis]);
const market = useMemo(() => adaptMarketCard(analysis), [analysis]);
const engineering = useMemo(() => adaptEngineeringCard(analysis), [analysis]);
const risk = useMemo(() => adaptRiskGauge(analysis), [analysis]);
const build = useMemo(() => adaptBuildabilityGauge(analysis), [analysis]);
const invest = useMemo(
() => adaptInvestScore(analysis, forecastReport),
[analysis, forecastReport],
);
const answer = useMemo(() => {
return (q: string): string => {
const t = q.toLowerCase();
const has = (...ws: string[]) => ws.some((w) => t.includes(w));
if (has("площад", "гектар", "га")) {
return joinReal("Площадь участка", passport.area.value, passport.area.isReal);
}
if (has("строит", "застрой", "ксит", "высот", "плотн", "регламент", "зона")) {
const zone = urban.rows.find((r) => r.key === "Зона")?.field;
const far = urban.rows.find((r) => r.key === "Плотность (КСИТ)")?.field;
const height = urban.rows.find((r) => r.key === "Пред. высота")?.field;
const buildPct = build.value == null ? "—" : `${build.value}%`;
return (
`${joinReal("Зона", zone?.value ?? "—", zone?.isReal ?? false)} ` +
`${joinReal("Плотность (КСИТ)", far?.value ?? "—", far?.isReal ?? false)} ` +
`${joinReal("Предельная высота", height?.value ?? "—", height?.isReal ?? false)} ` +
`Buildability ${buildPct} (${build.footnote ?? "предв."}).`
);
}
if (has("цен", "медиан", "рынок", "стоим", "спрос")) {
const median = market.rows.find((r) => r.key === "Медиана цены")?.field;
const demand = market.rows.find((r) => r.key === "Уровень спроса")?.field;
const comp = market.rows.find((r) => r.key === "Конкурентов")?.field;
return (
`${joinReal("Медиана цены в районе", median?.value ?? "—", median?.isReal ?? false)} ` +
`${joinReal("Уровень спроса", demand?.value ?? "—", demand?.isReal ?? false)} ` +
`${joinReal("Конкурентов", comp?.value ?? "—", comp?.isReal ?? false)}`
);
}
if (has("риск")) {
const pct = risk.value == null ? "—" : `${risk.value}%`;
return `Сводный риск ${pct}${risk.label} (${risk.footnote ?? "предв."}).`;
}
if (has("инженер", "сет", "подключ", "тепло", "вод", "электр", "газ", "канализ")) {
if (engineering.rows.length === 0) {
return "Данных по инженерным сетям пока нет (—).";
}
const parts = engineering.rows.map(
(r) => `${r.key} ${r.field.value}`,
);
return `Расстояния до точек подключения: ${parts.join(", ")}.`;
}
if (has("инвест", "скор", "оценк", "потенциал")) {
return (
`${joinReal("Инвест-скор", invest.score.value, invest.score.isReal)} ` +
`${joinReal("Сигнал покупки", invest.buySignal.value, invest.buySignal.isReal)}`
);
}
return (
"Я отвечаю по данным анализа участка: площадь, градрегламент, рынок, " +
"инженерия, риски и инвест-скор. Где данных нет — отвечаю честным «—»."
);
};
}, [passport, urban, market, engineering, risk, build, invest]);
const [msgs, setMsgs] = useState<ChatMsg[]>([
{
who: "bot",
text: `Здравствуйте! Я помощник по участку ${analysis.cad_num}. Спросите про площадь, регламент, рынок, инженерию или риски.`,
},
]);
const [typing, setTyping] = useState(false);
const [input, setInput] = useState("");
function send(q: string) {
const question = q.trim();
if (!question) return;
setMsgs((m) => [...m, { who: "user", text: question }]);
setInput("");
setTyping(true);
window.setTimeout(() => {
setTyping(false);
setMsgs((m) => [...m, { who: "bot", text: answer(question) }]);
}, 420);
}
return (
<aside
className={`${styles.chatPanel} ${open ? styles.chatPanelOpen : ""}`}
role="dialog"
aria-label="Чат по участку"
aria-hidden={open ? undefined : "true"}
>
<div className={styles.chatH}>
<div className={styles.chL}>
<span className={styles.chIc}>
<svg viewBox="0 0 24 24" fill="none">
<path
d="M3 9 L13 16 L11.5 11 L23 7 L13.5 18 L13 23 L9 17 Z"
fill="currentColor"
/>
</svg>
</span>
<div>
<div className={styles.chT}>ПТИЦА · чат по участку</div>
<div className={styles.chS}>{analysis.cad_num}</div>
</div>
</div>
<button
type="button"
className={styles.drawerClose}
onClick={onClose}
aria-label="Закрыть"
>
</button>
</div>
<div className={styles.chatMsgs}>
{msgs.map((m, i) => (
<div
key={i}
className={`${styles.msg} ${m.who === "bot" ? styles.msgBot : styles.msgUser}`}
>
{m.text}
</div>
))}
{typing && (
<div className={styles.chatTyping} aria-hidden="true">
<i />
<i />
<i />
</div>
)}
</div>
<div className={styles.chatChips}>
{CHIPS.map((c) => (
<button
key={c}
type="button"
className={styles.chatChip}
onClick={() => send(c)}
>
{c}
</button>
))}
</div>
<form
className={styles.chatInput}
onSubmit={(e) => {
e.preventDefault();
send(input);
}}
>
<input
type="text"
autoComplete="off"
placeholder="Спросить про участок…"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<button type="submit" className={styles.chatSend} aria-label="Отправить">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M4 12l16-8-6 16-3-6-7-2z" />
</svg>
</button>
</form>
</aside>
);
}

View file

@ -0,0 +1,304 @@
"use client";
/**
* V2Cockpit the composed «ПТИЦА» cockpit (prototype #app). Owns:
* - theme (dark/light) with a floating / toggle + localStorage persistence
* (SSR-guarded), scoped via `data-theme` on `.v2Root`,
* - the open-drawer key (rail + card "ПОДРОБНЕЕ" triggers), deep-linked via
* `?drawer=<key>` so drawers are linkable / back-button friendly,
* - the chat panel open-state + a shared scrim backing both drawer and chat,
* - the 4-row cockpit grid (map+passport+gauges / 6 scan cards / lower / bottom).
*
* All rendered values flow through the ptica-adapt layer REAL where /analyze
* provides it, honest «» otherwise. No fabricated numbers.
*/
import { useCallback, useEffect, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
import type { ParcelAnalysis } from "@/types/site-finder";
import type { ForecastReport } from "@/types/forecast";
import {
adaptUrbanCard,
adaptRestrictionsCard,
adaptEngineeringCard,
adaptMarketCard,
adaptEconomyCard,
adaptEnvironmentCard,
} from "@/components/site-finder/ptica/ptica-adapt";
import { V2Topbar } from "@/components/site-finder/ptica-v2/V2Topbar";
import { V2Rail } from "@/components/site-finder/ptica-v2/V2Rail";
import { V2Map } from "@/components/site-finder/ptica-v2/V2Map";
import { V2Passport } from "@/components/site-finder/ptica-v2/V2Passport";
import { V2RightColumn } from "@/components/site-finder/ptica-v2/V2RightColumn";
import { V2ScanCard } from "@/components/site-finder/ptica-v2/V2ScanCard";
import { V2RiskCard } from "@/components/site-finder/ptica-v2/V2RiskCard";
import { V2LowerGrid } from "@/components/site-finder/ptica-v2/V2LowerGrid";
import { V2BottomGrid } from "@/components/site-finder/ptica-v2/V2BottomGrid";
import { V2Drawer } from "@/components/site-finder/ptica-v2/V2Drawer";
import { V2Chat } from "@/components/site-finder/ptica-v2/V2Chat";
import {
asV2DrawerKey,
getV2DrawerEntry,
type V2DrawerKey,
} from "@/components/site-finder/ptica-v2/v2-drawer-registry";
type Theme = "dark" | "light";
const THEME_KEY = "ptica-v2-theme";
interface Props {
analysis: ParcelAnalysis;
forecastReport?: ForecastReport;
}
export function V2Cockpit({ analysis, forecastReport }: Props) {
// ── Theme (scoped, localStorage-persisted, SSR-guarded) ─────────────────────
const [theme, setTheme] = useState<Theme>("dark");
useEffect(() => {
if (typeof window === "undefined") return;
try {
const saved = window.localStorage.getItem(THEME_KEY);
if (saved === "light" || saved === "dark") setTheme(saved);
} catch {
// ignore storage errors
}
}, []);
const toggleTheme = useCallback(() => {
setTheme((prev) => {
const next: Theme = prev === "dark" ? "light" : "dark";
try {
window.localStorage.setItem(THEME_KEY, next);
} catch {
// ignore storage errors
}
return next;
});
}, []);
// ── Drawer open-state + deep-link (?drawer=<key>) ───────────────────────────
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [openDrawer, setOpenDrawer] = useState<V2DrawerKey | null>(null);
const [chatOpen, setChatOpen] = useState(false);
const [toast, setToast] = useState<string | null>(null);
const drawerParam = searchParams.get("drawer");
useEffect(() => {
setOpenDrawer(asV2DrawerKey(drawerParam));
}, [drawerParam]);
const syncUrl = useCallback(
(key: V2DrawerKey | null) => {
const params = new URLSearchParams(searchParams.toString());
if (key) params.set("drawer", key);
else params.delete("drawer");
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
},
[router, pathname, searchParams],
);
const handleOpenDrawer = useCallback(
(key: V2DrawerKey) => {
setChatOpen(false);
setOpenDrawer(key);
syncUrl(key);
},
[syncUrl],
);
const handleCloseDrawer = useCallback(() => {
setOpenDrawer(null);
syncUrl(null);
}, [syncUrl]);
const openChat = useCallback(() => {
setOpenDrawer(null);
syncUrl(null);
setChatOpen(true);
}, [syncUrl]);
const closeChat = useCallback(() => setChatOpen(false), []);
// ESC closes drawer + chat
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
setOpenDrawer(null);
setChatOpen(false);
syncUrl(null);
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [syncUrl]);
// Toast auto-dismiss
useEffect(() => {
if (!toast) return;
const id = window.setTimeout(() => setToast(null), 2400);
return () => window.clearTimeout(id);
}, [toast]);
const handleExport = useCallback((fmt: string) => {
setToast(`Экспорт «${fmt}» доступен на странице глубокого анализа`);
}, []);
const drawerEntry = openDrawer
? getV2DrawerEntry(openDrawer, analysis, forecastReport, handleExport)
: null;
const scrimOpen = Boolean(openDrawer) || chatOpen;
// ── Scan-card view-models (ptica-adapt) ─────────────────────────────────────
const urban = adaptUrbanCard(analysis);
const restrictions = adaptRestrictionsCard(analysis);
const engineering = adaptEngineeringCard(analysis);
const market = adaptMarketCard(analysis);
const economy = adaptEconomyCard();
const environment = adaptEnvironmentCard(analysis);
return (
<div className={styles.v2Root} data-theme={theme}>
<div className={styles.app}>
<V2Topbar cad={analysis.cad_num} />
<div className={styles.body}>
<V2Rail openDrawer={openDrawer} onOpenDrawer={handleOpenDrawer} />
<main className={styles.content}>
{/* ROW 1 — map · passport · gauges */}
<section className={styles.row1}>
<V2Map analysis={analysis} />
<V2Passport analysis={analysis} onOpenDrawer={handleOpenDrawer} />
<V2RightColumn
analysis={analysis}
forecastReport={forecastReport}
onOpenDrawer={handleOpenDrawer}
/>
</section>
{/* ROW 2 — 6 scan cards */}
<section className={styles.row2}>
<V2ScanCard card={urban} drawer="potential" onOpenDrawer={handleOpenDrawer} />
<V2ScanCard
card={restrictions}
drawer="restrictions"
onOpenDrawer={handleOpenDrawer}
/>
<V2ScanCard
card={engineering}
drawer="engineering"
onOpenDrawer={handleOpenDrawer}
/>
<V2ScanCard card={market} drawer="market" onOpenDrawer={handleOpenDrawer} />
<V2ScanCard
card={economy}
drawer="economics"
onOpenDrawer={handleOpenDrawer}
/>
<V2RiskCard analysis={analysis} onOpenDrawer={handleOpenDrawer} />
</section>
{/* ROW 3 — ОКС · potential · product · insolation */}
<V2LowerGrid
analysis={analysis}
forecastReport={forecastReport}
onOpenDrawer={handleOpenDrawer}
/>
{/* ROW 4 — clearance · legal · verdict */}
<V2BottomGrid
analysis={analysis}
forecastReport={forecastReport}
onOpenDrawer={handleOpenDrawer}
/>
{/* Среда · экология honest atmospheric scan (not in the prototype
4-row grid but ported from the adapter so the data isn't dropped). */}
<section className={styles.row2}>
<V2ScanCard
card={environment}
drawer="restrictions"
onOpenDrawer={handleOpenDrawer}
/>
</section>
</main>
</div>
<footer className={styles.footer}>
<div className={styles.left}>
<span>
МОДЕЛЬ ОЦЕНКИ: <b>ПТИЦА · Site Finder v2</b>
</span>
<span className={styles.footDot}>·</span>
<span>
УЧАСТОК: <b>{analysis.cad_num}</b>
</span>
</div>
<div className={styles.right}>
НЕ ЯВЛЯЕТСЯ ПУБЛИЧНОЙ ОФЕРТОЙ · ИНФОРМАЦИЯ НОСИТ ОЦЕНОЧНЫЙ ХАРАКТЕР
</div>
</footer>
</div>
{/* Theme toggle */}
<button
type="button"
className={styles.themeToggle}
onClick={toggleTheme}
aria-label="Переключить тему"
title="Переключить тему"
>
{theme === "light" ? "☾" : "☀"}
</button>
{/* Chat FAB */}
<button
type="button"
className={styles.chatFab}
onClick={openChat}
aria-label="Чат по участку"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M21 12a8 8 0 0 1-11.5 7.2L4 20l1-4.3A8 8 0 1 1 21 12z" />
<path d="M8.5 11.5h.01M12 11.5h.01M15.5 11.5h.01" />
</svg>
</button>
{/* Shared scrim */}
<div
className={`${styles.scrim} ${scrimOpen ? styles.scrimOpen : ""}`}
onClick={() => {
handleCloseDrawer();
closeChat();
}}
aria-hidden="true"
/>
{/* Drawer */}
<V2Drawer
open={Boolean(drawerEntry)}
eyebrow={drawerEntry?.eyebrow}
title={drawerEntry?.title ?? ""}
sum={drawerEntry?.sum}
onClose={handleCloseDrawer}
>
{drawerEntry?.content}
</V2Drawer>
{/* Chat */}
<V2Chat
analysis={analysis}
forecastReport={forecastReport}
open={chatOpen}
onClose={closeChat}
/>
{/* Toast */}
{toast && (
<div className={styles.toast + " " + styles.toastShow}>{toast}</div>
)}
</div>
);
}

View file

@ -0,0 +1,49 @@
"use client";
/**
* V2Drawer right slide-in panel (prototype `.drawer`, 480px). Controlled by
* `open`; renders the eyebrow / title / summary header + scrollable body. The
* shared scrim is owned by V2Cockpit (so it can also back the chat panel).
*/
import type { ReactNode } from "react";
import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
interface Props {
open: boolean;
eyebrow?: string;
title: string;
sum?: string;
onClose: () => void;
children: ReactNode;
}
export function V2Drawer({ open, eyebrow, title, sum, onClose, children }: Props) {
return (
<aside
className={`${styles.drawer} ${open ? styles.drawerOpen : ""}`}
role="dialog"
aria-modal={open ? "true" : undefined}
aria-hidden={open ? undefined : "true"}
aria-label={title}
>
<div className={styles.drawerH}>
<div className={styles.dhLeft}>
{eyebrow && <div className={styles.dhEyebrow}>{eyebrow}</div>}
<div className={styles.dhTitle}>{title}</div>
{sum && <div className={styles.dhSum}>{sum}</div>}
</div>
<button
type="button"
className={styles.drawerClose}
onClick={onClose}
aria-label="Закрыть"
>
</button>
</div>
<div className={styles.drawerB}>{children}</div>
</aside>
);
}

View file

@ -0,0 +1,95 @@
"use client";
/**
* V2DrawerPrimitives shared drawer-content building blocks (prototype `.dsec` /
* `.drow` / `.dsource` / `.dfac`). Each consumes the ptica-adapt view-models so
* REAL data renders normally and placeholders render muted with their caption as
* a title tooltip.
*/
import type { ReactNode } from "react";
import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
import type {
DrawerKvRow,
PticaFactorBar,
} from "@/components/site-finder/ptica/ptica-adapt";
import { V2ShieldIcon } from "@/components/site-finder/ptica-v2/V2Icons";
export function DSec({
title,
badge,
children,
}: {
title: string;
badge?: ReactNode;
children: ReactNode;
}) {
return (
<div className={styles.dsec}>
<div className={styles.dsecT}>
{title}
{badge}
</div>
{children}
</div>
);
}
export function DRow({ row }: { row: DrawerKvRow }) {
return (
<div className={styles.drow}>
<span className={styles.k}>{row.k}</span>
<span
className={`${styles.v} ${styles.mono} ${row.field.isReal ? "" : styles.dash}`}
title={row.field.isReal ? undefined : row.field.caption}
>
{row.field.value}
</span>
</div>
);
}
export function DRows({ rows }: { rows: DrawerKvRow[] }) {
return (
<>
{rows.map((r) => (
<DRow key={r.k} row={r} />
))}
</>
);
}
const BAR_COLOR: Record<PticaFactorBar["tone"], string> = {
good: "var(--accent-green)",
bad: "var(--accent-red)",
neutral: "var(--accent-cyan)",
};
export function DFactor({ bar }: { bar: PticaFactorBar }) {
const pct = Math.max(0, Math.min(100, bar.pct));
return (
<div className={styles.dfac}>
<div className={styles.dfacH}>
<span>{bar.name}</span>
<b>{bar.value}</b>
</div>
<div className={styles.dfacTrack}>
<i style={{ width: `${pct}%`, background: BAR_COLOR[bar.tone] }} />
</div>
</div>
);
}
export function DNote({ children }: { children: ReactNode }) {
return <div className={styles.dnote}>{children}</div>;
}
export function DSource({ children }: { children: ReactNode }) {
return (
<div className={styles.dsource}>
<V2ShieldIcon />
{children}
</div>
);
}

View file

@ -0,0 +1,72 @@
"use client";
/**
* V2Gauge SVG progress ring (prototype `.gauge-wrap` / `.minigauge`). Renders a
* faint track + a colored arc whose length maps a 0..100 value. `null` value
* muted "—" center (honest no-data, never a fake percentage). Tone selects the
* arc color via the scoped CSS tokens.
*
* Geometry mirrors the prototype: r=50 on a 120-box (big), r=30 on a 74-box
* (mini), r=26 on a 66-box (buy). dashoffset = circumference × (1 value/100).
*/
import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
import type { PticaGauge } from "@/components/site-finder/ptica/ptica-adapt";
type Size = "big" | "mini" | "buy";
const GEOM: Record<Size, { box: number; r: number; stroke: number }> = {
big: { box: 120, r: 50, stroke: 9 },
mini: { box: 74, r: 30, stroke: 7 },
buy: { box: 66, r: 26, stroke: 7 },
};
// Tone → stroke color (map exception: Leaflet/SVG can't read CSS custom props in
// stroke attr reliably across themes, so we reference the var() here directly).
const TONE_STROKE: Record<PticaGauge["tone"], string> = {
good: "var(--accent-green)",
warn: "var(--accent-yellow)",
bad: "var(--accent-red)",
neutral: "var(--accent-cyan)",
};
interface Props {
gauge: PticaGauge;
size?: Size;
}
export function V2Gauge({ gauge, size = "big" }: Props) {
const { box, r, stroke } = GEOM[size];
const c = box / 2;
const circ = 2 * Math.PI * r;
const pct = gauge.value == null ? 0 : Math.max(0, Math.min(100, gauge.value));
const offset = circ * (1 - pct / 100);
const color = gauge.value == null ? "var(--text-soft)" : TONE_STROKE[gauge.tone];
return (
<svg viewBox={`0 0 ${box} ${box}`} aria-hidden="true">
<circle
cx={c}
cy={c}
r={r}
fill="none"
stroke="var(--border-faint)"
strokeWidth={stroke}
/>
{gauge.value != null && (
<circle
cx={c}
cy={c}
r={r}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={circ}
strokeDashoffset={offset}
style={{ filter: "drop-shadow(0 0 5px var(--glow))" }}
/>
)}
</svg>
);
}

View file

@ -0,0 +1,53 @@
"use client";
/**
* V2Icons inline SVG glyphs ported 1:1 from the prototype (ptica-v2/index.html).
* Kept in one module so the topbar / rail / drawers / chat share identical marks.
*/
import type { ReactNode } from "react";
export function V2BirdMark(): ReactNode {
return (
<svg viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path
d="M3 9 L15 18 L13 12 L29 7 L17 20 L16 28 L11 21 Z"
fill="currentColor"
/>
</svg>
);
}
export function V2CheckIcon(): ReactNode {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
<path d="M5 13l4 4L19 7" />
</svg>
);
}
export function V2WarnIcon(): ReactNode {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="M12 3l9 16H3z" />
<path d="M12 10v4M12 17v.4" />
</svg>
);
}
export function V2ShieldIcon(): ReactNode {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4z" />
</svg>
);
}
export function V2CopyIcon(): ReactNode {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<rect x="9" y="9" width="11" height="11" rx="1.5" />
<path d="M5 15V5a1 1 0 0 1 1-1h9" />
</svg>
);
}

View file

@ -0,0 +1,201 @@
"use client";
/**
* V2LowerGrid row 3 (prototype `.row3`): ОКС · Development Potential ·
* Recommended Product · Инсоляционная матрица. ОКС is an honest empty-state (no
* реестр ОКС in /analyze); Potential reads REAL ёмкость from геометрия + НСПД;
* Product reads the §22 product_tz (class + quartile mix) or shows «после
* прогноза»; Insolation stays «скоро» (not computed yet).
*/
import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
import type { ParcelAnalysis } from "@/types/site-finder";
import type { ForecastReport } from "@/types/forecast";
import {
adaptOksLowerCard,
adaptPotentialCard,
adaptProductDrawer,
} from "@/components/site-finder/ptica/ptica-adapt";
import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry";
interface Props {
analysis: ParcelAnalysis;
forecastReport?: ForecastReport;
onOpenDrawer: (key: V2DrawerKey) => void;
}
export function V2LowerGrid({ analysis, forecastReport, onOpenDrawer }: Props) {
const oks = adaptOksLowerCard();
const potential = adaptPotentialCard(analysis);
const product = adaptProductDrawer(forecastReport);
return (
<section className={styles.row3}>
{/* ОКС */}
<div className={styles.card}>
<div className={styles.cardH}>
<div className={styles.ttl}>
<span className={styles.sectionTitle}>ОКС</span>
<span className={styles.sectionSub}>объекты кап. стр-ва</span>
</div>
</div>
<div className={styles.cardB} style={{ display: "flex", flexDirection: "column" }}>
<div className={styles.oksPlan}>
<div className={styles.oksMini}>
<svg viewBox="0 0 96 120" fill="none">
<rect
x="2"
y="2"
width="92"
height="116"
stroke="var(--accent-cyan)"
strokeWidth="1"
opacity=".5"
strokeDasharray="3 3"
/>
</svg>
</div>
<div className={styles.oksKv}>
{oks.rows.map((r) => (
<div key={r.k} className={styles.kv}>
<span className={styles.k}>{r.k}</span>
<span
className={`${styles.v} ${styles.dash}`}
title={r.field.caption}
>
{r.field.value}
</span>
</div>
))}
</div>
</div>
<div className={styles.moreRowAuto}>
<button
type="button"
className={styles.more}
onClick={() => onOpenDrawer("oks")}
>
ПОДРОБНЕЕ
</button>
</div>
</div>
</div>
{/* Development Potential */}
<div className={styles.card}>
<div className={styles.cardH}>
<div className={styles.ttl}>
<span className={styles.sectionTitle}>Development Potential</span>
</div>
</div>
<div className={styles.cardB} style={{ display: "flex", flexDirection: "column" }}>
<div className={styles.dpGrid}>
{potential.metrics.map((m) => (
<div
key={m.label}
className={m.label === "Продаваемая" ? `${styles.dpCell} ${styles.dpCellWide}` : styles.dpCell}
>
<div className={styles.k}>{m.label}</div>
<div
className={`${styles.v} ${m.isReal ? "" : styles.dash}`}
title={m.isReal ? undefined : m.caption}
>
{m.value}
{m.unit && <span className={styles.u}> {m.unit}</span>}
</div>
</div>
))}
</div>
<div className={styles.moreRowAuto}>
<button
type="button"
className={styles.more}
onClick={() => onOpenDrawer("potential")}
>
ПОДРОБНЕЕ
</button>
</div>
</div>
</div>
{/* Recommended Product */}
<div className={styles.card}>
<div className={styles.cardH}>
<div className={styles.ttl}>
<span className={styles.sectionTitle}>Recommended Product</span>
<span className={styles.sectionSub}>продуктовая стратегия</span>
</div>
</div>
<div className={styles.cardB} style={{ display: "flex", flexDirection: "column" }}>
<div className={styles.kv} style={{ paddingBottom: 6 }}>
<span className={styles.k}>Класс</span>
<span
className={`${styles.v} ${product.objClass.isReal ? "" : styles.dash}`}
title={product.objClass.isReal ? undefined : product.objClass.caption}
>
{product.objClass.value}
</span>
</div>
{product.available && product.mix.length > 0 ? (
<>
<div className={styles.qgTitle}>Квартирография</div>
<div>
{product.mix.map((m) => (
<div key={`${m.bucket}-${m.cls}`} className={styles.aptrow}>
<span className={styles.nm}>{m.bucket}</span>
<span className={styles.track}>
<i style={{ width: `${Math.max(0, Math.min(100, m.pct))}%` }} />
</span>
<span className={styles.pc}>{m.pct}%</span>
</div>
))}
</div>
</>
) : (
<div className={styles.emptyState}>после прогноза (Сценарии)</div>
)}
<div className={styles.moreRowAuto}>
<button
type="button"
className={styles.more}
onClick={() => onOpenDrawer("product")}
>
ПОДРОБНЕЕ
</button>
</div>
</div>
</div>
{/* Insolation matrix — «скоро» */}
<div className={`${styles.card} ${styles.insoCard}`}>
<div className={styles.cardH}>
<div className={styles.ttl}>
<span className={styles.sectionTitle}>Инсоляционная матрица</span>
</div>
</div>
<div className={styles.cardB}>
<div className={styles.insoIso}>
<svg viewBox="0 0 108 96" fill="none" stroke="currentColor" strokeWidth="1">
<path d="M18 60 L54 78 L54 50 L18 32 Z" fill="currentColor" fillOpacity=".08" />
<path d="M54 78 L90 60 L90 32 L54 50 Z" fill="currentColor" fillOpacity=".14" />
<path d="M18 32 L54 14 L90 32 L54 50 Z" fill="currentColor" fillOpacity=".2" />
<path
d="M30 50 L42 57 L42 40 L30 33 Z"
fill="var(--accent-yellow)"
fillOpacity=".25"
stroke="var(--accent-yellow)"
strokeOpacity=".4"
/>
</svg>
</div>
<div className={styles.insoTxt}>
<span className={`${styles.badge} ${styles.soon}`}>СКОРО</span>
<div className={styles.d}>
Расчёт инсоляции и КЕО в следующей версии
</div>
</div>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,57 @@
"use client";
/**
* V2Map the cockpit map card. Reuses the existing rich Leaflet plumbing
* (PticaMapInner parcel polygon, POI / ЖК / pipeline glyph pins, ЗОУИТ zones,
* connection-point lines, custom POI, dark/satellite tiles) via a SINGLE shared
* implementation loaded through dynamic(ssr:false) so Leaflet never touches the
* server. Connection points + custom POIs are fetched here (same TanStack hooks
* as PticaMap) and threaded into the inner map.
*
* The inner map brings its own overlay/legend/controls + base-layer toggle
* (Спутник | Схема) and dark CartoDB tiles, matching the prototype map block.
* The map title chip is rendered here over the card per the prototype `.map-head`.
*/
import dynamic from "next/dynamic";
import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
import type { ParcelAnalysis } from "@/types/site-finder";
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
import { useCustomPois } from "@/hooks/useCustomPois";
const PticaMapInner = dynamic(
() => import("@/components/site-finder/ptica/PticaMapInner"),
{
ssr: false,
loading: () => <div className={styles.mapLoading}>Загрузка карты</div>,
},
);
interface Props {
analysis: ParcelAnalysis;
}
export function V2Map({ analysis }: Props) {
const cad = analysis.cad_num;
const { data: connectionPoints } = useConnectionPoints(cad);
const { data: customPois } = useCustomPois(cad);
return (
<div className={`${styles.card} ${styles.mapCard} ${styles.brk}`}>
<span className={styles.bc1} />
<span className={styles.bc2} />
<div className={styles.mapMount}>
<PticaMapInner
analysis={analysis}
cad={cad}
connectionPoints={connectionPoints}
customPois={customPois}
/>
</div>
<div className={styles.mapHead}>
<span className={styles.ttl}>Карта участка</span>
</div>
</div>
);
}

View file

@ -0,0 +1,90 @@
"use client";
/**
* V2Passport «Паспорт участка» card (prototype `.passport`). Renders REAL ЕГРН
* fields via adaptPassport; absent fields show the honest «» placeholder. The
* "ПОДРОБНЕЕ →" link opens the passport drawer.
*/
import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
import type { ParcelAnalysis } from "@/types/site-finder";
import { adaptPassport } from "@/components/site-finder/ptica/ptica-adapt";
import type { PticaField } from "@/components/site-finder/ptica/ptica-adapt";
import { V2CopyIcon } from "@/components/site-finder/ptica-v2/V2Icons";
import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry";
function Cell({
label,
field,
wide,
big,
}: {
label: string;
field: PticaField;
wide?: boolean;
big?: boolean;
}) {
return (
<div className={`${styles.ppCell} ${wide ? styles.ppCellWide : ""}`}>
<div className={styles.k}>{label}</div>
<div
className={`${styles.vv} ${big ? styles.big : ""}`}
title={field.isReal ? undefined : field.caption}
>
{field.value}
</div>
</div>
);
}
interface Props {
analysis: ParcelAnalysis;
onOpenDrawer: (key: V2DrawerKey) => void;
}
export function V2Passport({ analysis, onOpenDrawer }: Props) {
const p = adaptPassport(analysis);
return (
<div className={`${styles.card} ${styles.passport}`}>
<div className={styles.cardH}>
<div className={styles.ttl}>
<span className={styles.sectionTitle}>Паспорт участка</span>
</div>
<button
type="button"
className={styles.more}
onClick={() => onOpenDrawer("parcel")}
>
ПОДРОБНЕЕ
</button>
</div>
<div className={styles.cardB}>
<div className={styles.ppId}>
<span className={styles.lbl}>ID</span>
<span className={`${styles.v} ${styles.mono}`}>{p.cadNum}</span>
<span className={styles.cp} aria-hidden="true">
<V2CopyIcon />
</span>
</div>
<div className={styles.ppGrid}>
<Cell label="Район" field={p.district} />
<Cell label="Площадь" field={p.area} big />
<Cell label="Адрес" field={p.address} />
<Cell label="ВРИ" field={p.vri} />
<Cell label="Категория земель" field={p.landCategory} />
<div className={styles.ppCell}>
<div className={styles.k}>Статус</div>
<div
className={p.status.isReal ? styles.statusOn : styles.vv}
title={p.status.isReal ? undefined : p.status.caption}
>
{p.status.value}
</div>
</div>
<Cell label="Обременения" field={p.encumbrances} wide />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,132 @@
"use client";
/**
* V2Rail left icon rail (prototype `.rail`). Each item opens its detail drawer
* via the shared `onOpenDrawer` callback (V2 drawer registry keys). The currently
* open drawer key highlights the matching rail item (prototype `.rail-on`).
*/
import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry";
interface RailItem {
key: string;
label: string;
drawer: V2DrawerKey;
icon: React.ReactNode;
}
const RAIL: RailItem[] = [
{
key: "parcel",
label: "Участок",
drawer: "parcel",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M3 7l9-4 9 4-9 4-9-4z" />
<path d="M3 7v6l9 4 9-4V7" />
</svg>
),
},
{
key: "potential",
label: "Потенциал",
drawer: "potential",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M3 17l5-5 4 4 8-9" />
<path d="M21 7v5h-5" />
</svg>
),
},
{
key: "product",
label: "Продукт",
drawer: "product",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
<rect x="4" y="3" width="16" height="18" rx="1" />
<path d="M8 8h8M8 12h8M8 16h5" />
</svg>
),
},
{
key: "economics",
label: "Экономика",
drawer: "economics",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M4 19h16M7 19V9m5 10V5m5 14v-6" />
</svg>
),
},
{
key: "risks",
label: "Риски",
drawer: "risks",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4z" />
<path d="M12 8v5" />
<circle cx="12" cy="16" r=".6" fill="currentColor" />
</svg>
),
},
{
key: "compare",
label: "Сравнение",
drawer: "compare",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M4 6h7v14H4zM13 10h7v10h-7z" />
</svg>
),
},
{
key: "reports",
label: "Отчёты",
drawer: "reports",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M6 3h9l3 3v15H6z" />
<path d="M14 3v4h4M9 12h6M9 16h6" />
</svg>
),
},
];
interface Props {
openDrawer: V2DrawerKey | null;
onOpenDrawer: (key: V2DrawerKey) => void;
}
export function V2Rail({ openDrawer, onOpenDrawer }: Props) {
return (
<aside className={styles.rail}>
{RAIL.map((item) => {
const active = openDrawer === item.drawer;
return (
<button
key={item.key}
type="button"
className={`${styles.railItem} ${active ? styles.railItemActive : ""}`}
aria-current={active ? "true" : undefined}
onClick={() => onOpenDrawer(item.drawer)}
>
{item.icon}
<span className={styles.rlbl}>{item.label}</span>
</button>
);
})}
<div className={styles.railSpacer} />
<div className={styles.railItem}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
<circle cx="12" cy="12" r="9" />
<path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.8.4-1 .9-1 1.7" />
<circle cx="12" cy="16.5" r=".6" fill="currentColor" />
</svg>
<span className={styles.rlbl}>Справка</span>
</div>
</aside>
);
}

View file

@ -0,0 +1,146 @@
"use client";
/**
* V2RightColumn row-1 right stack (prototype `.col-stack`): the Buildability
* gauge (SVG ring) over the Инвест-скор block. Both honest: the gauge shows a
* muted «» when there is no data; the invest-score shows «» until the §22
* forecast surfaces the overall_score. Values flow through ptica-adapt.
*/
import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
import type { ParcelAnalysis } from "@/types/site-finder";
import type { ForecastReport } from "@/types/forecast";
import {
adaptBuildabilityGauge,
adaptInvestScore,
} from "@/components/site-finder/ptica/ptica-adapt";
import type { PticaValueTone } from "@/components/site-finder/ptica/ptica-adapt";
import { V2Gauge } from "@/components/site-finder/ptica-v2/V2Gauge";
import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry";
const TONE_CLASS: Record<PticaValueTone, string> = {
good: "",
warn: "amb",
none: "none",
};
// Static spark bars (decorative only — prototype `.spark`).
const SPARK = [12, 16, 20, 15, 22, 17, 24];
interface Props {
analysis: ParcelAnalysis;
forecastReport?: ForecastReport;
onOpenDrawer: (key: V2DrawerKey) => void;
}
export function V2RightColumn({ analysis, forecastReport, onOpenDrawer }: Props) {
const gauge = adaptBuildabilityGauge(analysis);
const invest = adaptInvestScore(analysis, forecastReport);
const pct = gauge.value == null ? "—" : `${gauge.value}%`;
const gaugeColor =
gauge.value == null
? "var(--text-soft)"
: gauge.tone === "good"
? "var(--accent-green)"
: gauge.tone === "warn"
? "var(--accent-yellow)"
: gauge.tone === "bad"
? "var(--accent-red)"
: "var(--accent-cyan)";
return (
<div className={styles.colStack}>
{/* Buildability gauge */}
<div className={`${styles.card} ${styles.gaugeCard} ${styles.brk}`}>
<span className={styles.bc1} />
<span className={styles.bc2} />
<div className={styles.cardH}>
<div className={styles.ttl}>
<span className={styles.sectionTitle}>Buildability Index</span>
</div>
</div>
<div className={styles.cardB}>
<div className={styles.gaugeWrap}>
<V2Gauge gauge={gauge} size="big" />
<div className={styles.gaugeCenter}>
<span
className={`${styles.pct} ${gauge.value == null ? styles.mutedG : ""}`}
style={{ color: gaugeColor }}
>
{pct}
</span>
<span className={styles.cap} style={{ color: gaugeColor }}>
{gauge.label}
</span>
</div>
</div>
{gauge.footnote && (
<span className={styles.gaugeFoot}>{gauge.footnote}</span>
)}
</div>
</div>
{/* Invest score */}
<div className={`${styles.card} ${styles.invest}`}>
<div className={styles.cardH}>
<div className={styles.ttl}>
<span className={styles.sectionTitle}>Инвест-скор</span>
</div>
<button
type="button"
className={styles.more}
onClick={() => onOpenDrawer("economics")}
>
ПОДРОБНЕЕ
</button>
</div>
<div className={styles.cardB}>
<div className={styles.scoreRow}>
<div
className={`${styles.score} ${styles.mono} ${invest.score.isReal ? "" : styles.dashed}`}
title={invest.score.isReal ? undefined : invest.score.caption}
>
{invest.score.value}
<span className={styles.u}> /10</span>
</div>
<div className={styles.spark}>
<svg viewBox="0 0 70 26" fill="none" preserveAspectRatio="none">
{SPARK.map((h, i) => (
<rect
key={i}
x={2 + i * 9}
y={26 - h}
width="6"
height={h}
fill="currentColor"
opacity={0.35 + i * 0.035}
/>
))}
</svg>
</div>
</div>
<div className={styles.prRow}>
<div className={styles.pr}>
<div className={styles.k}>Потенциал</div>
<div
className={`${styles.vv} ${styles[TONE_CLASS[invest.potentialTone]] ?? ""}`}
title={invest.potential.isReal ? undefined : invest.potential.caption}
>
{invest.potential.value}
</div>
</div>
<div className={styles.pr}>
<div className={styles.k}>Риск</div>
<div
className={`${styles.vv} ${styles[TONE_CLASS[invest.riskTone]] ?? ""}`}
title={invest.risk.isReal ? undefined : invest.risk.caption}
>
{invest.risk.value}
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,62 @@
"use client";
/**
* V2RiskCard the «Риски» mini-gauge card (prototype `.risk-card`). Renders the
* derived risk proxy (adaptRiskGauge, «предв.») as a small SVG ring + label;
* muted «» when there is no data. Opens the risks drawer.
*/
import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
import type { ParcelAnalysis } from "@/types/site-finder";
import { adaptRiskGauge } from "@/components/site-finder/ptica/ptica-adapt";
import { V2Gauge } from "@/components/site-finder/ptica-v2/V2Gauge";
import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry";
interface Props {
analysis: ParcelAnalysis;
onOpenDrawer: (key: V2DrawerKey) => void;
}
export function V2RiskCard({ analysis, onOpenDrawer }: Props) {
const gauge = adaptRiskGauge(analysis);
const pct = gauge.value == null ? "—" : `${gauge.value}%`;
const color =
gauge.value == null
? "var(--text-soft)"
: gauge.tone === "good"
? "var(--accent-green)"
: gauge.tone === "warn"
? "var(--accent-yellow)"
: "var(--accent-red)";
return (
<div className={`${styles.card} ${styles.riskCard}`}>
<div className={styles.cardH}>
<div className={styles.ttl}>
<span className={styles.sectionTitle}>Риски</span>
</div>
</div>
<div className={styles.cardB}>
<div className={styles.rk}>Сводный риск</div>
<div className={styles.minigauge}>
<V2Gauge gauge={gauge} size="mini" />
<div className={styles.c}>
<span className={styles.p} style={{ color }}>
{pct}
</span>
</div>
</div>
<div className={styles.rk} style={{ color }}>
{gauge.label}
</div>
<button
type="button"
className={styles.more}
onClick={() => onOpenDrawer("risks")}
>
ПОДРОБНЕЕ
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,116 @@
"use client";
/**
* V2ScanCard generic «scan» card for row 2 (prototype `.row2 .card`). Renders a
* PticaScanCard view-model: a title (+ optional badge), kv-rows OR status-rows
* (dot + state, used by Инженерия), an empty-state, and a "ПОДРОБНЕЕ →" link to
* the card's drawer. All values come from the ptica-adapt layer (REAL or honest
* «»).
*/
import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
import type {
PticaScanCard,
PticaScanRow,
PticaStatusTone,
} from "@/components/site-finder/ptica/ptica-adapt";
import type { V2DrawerKey } from "@/components/site-finder/ptica-v2/v2-drawer-registry";
const STATUS_COLOR: Record<PticaStatusTone, string> = {
ok: "var(--accent-green)",
warn: "var(--accent-orange)",
bad: "var(--accent-red)",
};
const STATUS_LABEL: Record<PticaStatusTone, string> = {
ok: "Доступно",
warn: "Огранич.",
bad: "Нет",
};
const STATUS_CLASS: Record<PticaStatusTone, string> = {
ok: styles.ok,
warn: styles.warnc,
bad: styles.warnc,
};
function KvRow({ row }: { row: PticaScanRow }) {
return (
<div className={styles.kv}>
<span className={styles.k}>{row.key}</span>
<span
className={`${styles.v} ${styles.mono} ${row.field.isReal ? "" : styles.dash}`}
title={row.field.isReal ? undefined : row.field.caption}
>
{row.field.value}
</span>
</div>
);
}
function StatusRow({ row }: { row: PticaScanRow }) {
const tone = row.status ?? "ok";
return (
<div className={styles.engRow}>
<span className={styles.nm}>
<span
className={styles.sd}
style={{ color: STATUS_COLOR[tone] }}
aria-hidden="true"
/>
{row.key}
</span>
<span className={styles.st}>
<span className={`${styles.stt} ${STATUS_CLASS[tone]}`}>
{STATUS_LABEL[tone]}
</span>
<span className={styles.dm} title={row.field.isReal ? undefined : row.field.caption}>
{row.field.value}
</span>
</span>
</div>
);
}
interface Props {
card: PticaScanCard;
drawer: V2DrawerKey;
onOpenDrawer: (key: V2DrawerKey) => void;
}
export function V2ScanCard({ card, drawer, onOpenDrawer }: Props) {
return (
<div className={styles.card}>
<div className={styles.cardH}>
<div className={styles.ttl}>
<span className={styles.sectionTitle}>{card.title}</span>
{card.badge && <span className={styles.badge}>{card.badge}</span>}
</div>
</div>
<div className={styles.cardB}>
{card.rows.length === 0 ? (
<div className={styles.emptyState}>
{card.emptyState ?? "Нет данных"}
</div>
) : (
card.rows.map((row) =>
row.status ? (
<StatusRow key={row.key} row={row} />
) : (
<KvRow key={row.key} row={row} />
),
)
)}
<div className={styles.moreRow}>
<button
type="button"
className={styles.more}
onClick={() => onOpenDrawer(drawer)}
>
ПОДРОБНЕЕ
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,104 @@
"use client";
/**
* V2Topbar cockpit top bar (prototype `.topbar`): ПТИЦА wordmark (links to
* Site Finder), 4 режимных tabs (visual the cockpit is a single analysis view),
* a live clock + version chip + two icon buttons.
*
* The clock is mounted-only (state gated by an effect) to avoid an SSR/client
* hydration mismatch; the effect drives a UI clock no HTTP which is the
* sanctioned use of useEffect here.
*/
import { useEffect, useState } from "react";
import Link from "next/link";
import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
import { V2BirdMark } from "@/components/site-finder/ptica-v2/V2Icons";
const TABS = [
"Анализ участка",
"Сценарии",
"Отчёты",
"Сравнение",
] as const;
interface Props {
cad: string;
}
export function V2Topbar({ cad }: Props) {
const [now, setNow] = useState<{ time: string; date: string } | null>(null);
useEffect(() => {
function tick() {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
setNow({
time: `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`,
date: `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())}`,
});
}
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, []);
return (
<header className={styles.topbar}>
<Link
href="/site-finder"
className={styles.brand}
aria-label="На главную — Site Finder"
>
<span className={styles.brandLogo} aria-hidden="true">
<V2BirdMark />
</span>
<div>
<div className={styles.brandName}>ПТИЦА</div>
<div className={styles.brandSub}>
платформа территориального
<br />
информационно-цифрового анализа
</div>
</div>
</Link>
<nav className={styles.tabs} aria-label="Режимы">
{TABS.map((label, i) => (
<span
key={label}
className={`${styles.tab} ${i === 0 ? styles.tabActive : ""}`}
aria-current={i === 0 ? "page" : undefined}
>
{label}
</span>
))}
</nav>
<div className={styles.topbarRight}>
<div className={styles.clock}>
<div className={styles.time} suppressHydrationWarning>
{now?.time ?? "—"}
</div>
<div className={styles.date} suppressHydrationWarning>
{now?.date ?? "—"}
</div>
</div>
<div className={styles.ver}>SITE FINDER v2</div>
<div className={styles.tbIcons}>
<span className={styles.tbIc} title={`Участок ${cad}`}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14" />
</svg>
</span>
<span className={styles.tbIc} aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
</svg>
</span>
</div>
</div>
</header>
);
}

View file

@ -0,0 +1,465 @@
"use client";
/**
* v2-drawer-registry the 11 detail drawers of the v2 cockpit (prototype
* `DRAWERS`). Each entry = { rail?, eyebrow, title, sum, content }. Content is
* built from the ptica-adapt drawer view-models so REAL data renders and absent
* fields stay honest «». Mirrors the prototype 1:1 in copy + layout; demo
* numbers from the prototype are NOT hard-coded values come from /analyze.
*/
import type { ReactNode } from "react";
import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
import type { ParcelAnalysis } from "@/types/site-finder";
import type { ForecastReport } from "@/types/forecast";
import {
adaptPassportDrawer,
adaptPotentialDrawer,
adaptBuildabilityDrawer,
adaptProductDrawer,
adaptEconomyDrawer,
adaptRisksDrawer,
adaptRestrictionsDrawer,
adaptEngineeringDrawer,
adaptMarketDrawer,
adaptOksDrawer,
} from "@/components/site-finder/ptica/ptica-adapt";
import {
DSec,
DRow,
DRows,
DFactor,
DNote,
DSource,
} from "@/components/site-finder/ptica-v2/V2DrawerPrimitives";
export const V2_DRAWER_KEYS = [
"parcel",
"potential",
"product",
"economics",
"risks",
"compare",
"reports",
"restrictions",
"engineering",
"market",
"oks",
] as const;
export type V2DrawerKey = (typeof V2_DRAWER_KEYS)[number];
export function asV2DrawerKey(
value: string | null | undefined,
): V2DrawerKey | null {
if (!value) return null;
return (V2_DRAWER_KEYS as readonly string[]).includes(value)
? (value as V2DrawerKey)
: null;
}
export interface V2DrawerEntry {
eyebrow: string;
title: string;
sum: string;
content: ReactNode;
}
// ── Reports / export buttons (demo — actual export is in the heavy analysis page) ─
const EXPORT_FORMATS: Array<{ id: string; sub: string }> = [
{ id: "PDF", sub: "сводный паспорт" },
{ id: "CSV", sub: "таблица метрик" },
{ id: "DOCX §22", sub: "пояснит. записка" },
{ id: "PPTX", sub: "презентация" },
];
function ReportsContent({ onExport }: { onExport: (fmt: string) => void }) {
return (
<>
<DSec title="Форматы">
<div className={styles.dexport}>
{EXPORT_FORMATS.map((f) => (
<button
key={f.id}
type="button"
className={styles.dbtn}
onClick={() => onExport(f.id)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M6 3h9l3 3v15H6z" />
<path d="M14 3v4h4" />
</svg>
<span>
<b>{f.id}</b>
<span className={styles.ds}>{f.sub}</span>
</span>
</button>
))}
</div>
</DSec>
<DNote>
Полная выгрузка отчётов доступна на странице глубокого анализа участка.
</DNote>
<DSource>Источник: генератор отчётов ПТИЦА</DSource>
</>
);
}
// ── Compare (advisory — single-parcel cockpit; full сравнение в отдельном модуле) ─
function CompareContent({ cad }: { cad: string }) {
return (
<>
<DSec title="Текущий участок">
<DRow row={{ k: "Кадастровый номер", field: { value: cad, isReal: true } }} />
</DSec>
<DNote>
Сравнение с участками-кандидатами доступно в модуле «Сравнение» Site
Finder выберите несколько участков, чтобы сопоставить их по ёмкости,
рынку и риску.
</DNote>
<DSource>Источник: реестр кандидатов Site Finder</DSource>
</>
);
}
// ── Registry ──────────────────────────────────────────────────────────────────
export function getV2DrawerEntry(
key: V2DrawerKey,
analysis: ParcelAnalysis,
forecastReport: ForecastReport | undefined,
onExport: (fmt: string) => void,
): V2DrawerEntry {
switch (key) {
case "parcel": {
const d = adaptPassportDrawer(analysis);
return {
eyebrow: "01 · ЕГРН",
title: "Паспорт участка",
sum: d.summary,
content: (
<>
<DSec title="Реестр (ЕГРН / НСПД)">
<DRows rows={d.registry} />
</DSec>
{d.zouitOverlaps.length > 0 && (
<DSec
title="ЗОУИТ"
badge={
<span className={`${styles.dbadge} ${styles.cyan} ${styles.dbadgeRight}`}>
{d.zouitCount} зон
</span>
}
>
{d.zouitOverlaps.map((z, i) => (
<DRow
key={`${z.layer}-${i}`}
row={{ k: z.name, field: { value: z.sub, isReal: true } }}
/>
))}
</DSec>
)}
<DSource>Источник: ЕГРН · Росреестр · НСПД</DSource>
</>
),
};
}
case "potential": {
const cap = adaptPotentialDrawer(analysis);
const build = adaptBuildabilityDrawer(analysis);
return {
eyebrow: "02 · ГРАДРЕГЛАМЕНТ",
title: "Потенциал застройки",
sum: cap.summary,
content: (
<>
<DSec title="Ёмкость и регламент">
<DRows rows={cap.rows} />
</DSec>
<DSec
title="Buildability index"
badge={
<span className={`${styles.dbadge} ${styles.cyan} ${styles.dbadgeRight}`}>
{build.gauge.value == null ? "—" : `${build.gauge.value}%`} ·
предв.
</span>
}
>
{build.factors.map((f) => (
<DFactor key={f.name} bar={f} />
))}
</DSec>
<DNote>{build.summary}</DNote>
<DSource>Источник: ПЗЗ / НСПД · расчёт ПТИЦА</DSource>
</>
),
};
}
case "product": {
const d = adaptProductDrawer(forecastReport);
return {
eyebrow: "03 · ПРОДУКТ",
title: "Рекомендованный продукт",
sum: d.summary,
content: (
<>
<DSec title="Рекомендация">
<DRow row={{ k: "Класс", field: d.objClass }} />
</DSec>
{d.available && d.mix.length > 0 && (
<DSec title="Квартирография">
{d.mix.map((m, i) => (
<DRow
key={`${m.bucket}-${i}`}
row={{
k: m.bucket,
field: { value: `${m.pct}%`, isReal: true },
}}
/>
))}
</DSec>
)}
{d.usp.length > 0 && (
<DSec title="USP">
{d.usp.map((u, i) => (
<DRow
key={i}
row={{ k: u.cls, field: { value: u.text, isReal: true } }}
/>
))}
</DSec>
)}
<DNote>
{d.available
? "Класс и квартирография — рекомендация прогноза §22 (advisory)."
: "Продуктовая стратегия формируется в прогнозе §22 — откройте «Сценарии»."}
</DNote>
<DSource>Источник: продуктовая модель ПТИЦА</DSource>
</>
),
};
}
case "economics": {
const d = adaptEconomyDrawer();
return {
eyebrow: "04 · ФИНМОДЕЛЬ",
title: "Экономика проекта",
sum: d.summary,
content: (
<>
<DSec title="Финансовая модель">
<DRows rows={d.rows} />
</DSec>
<DNote>{d.summary}</DNote>
<DSource>Источник: финмодель ПТИЦА (предв.)</DSource>
</>
),
};
}
case "risks": {
const d = adaptRisksDrawer(analysis);
return {
eyebrow: "05 · РИСКИ",
title: "Риски участка",
sum: d.summary,
content: (
<>
<DSec
title="Сводный риск"
badge={
<span className={`${styles.dbadge} ${styles.cyan} ${styles.dbadgeRight}`}>
{d.gauge.value == null ? "—" : `${d.gauge.value}%`} ·{" "}
{d.gauge.label}
</span>
}
>
{d.breakdown.map((f) => (
<DFactor key={f.name} bar={f} />
))}
</DSec>
<DSec title="Блокеры и предупреждения">
<DRow
row={{
k: "Блокеры",
field: {
value: String(d.blockers.length),
isReal: true,
},
}}
/>
<DRow
row={{
k: "Предупреждения",
field: {
value: String(d.warnings.length),
isReal: true,
},
}}
/>
</DSec>
<DNote>{d.summary}</DNote>
<DSource>Источник: гейт-модель ПТИЦА (предв.)</DSource>
</>
),
};
}
case "compare":
return {
eyebrow: "06 · СРАВНЕНИЕ",
title: "Сравнение участков",
sum: "Сопоставление участков-кандидатов по ключевым метрикам.",
content: <CompareContent cad={analysis.cad_num} />,
};
case "reports":
return {
eyebrow: "07 · ЭКСПОРТ",
title: "Отчёты и экспорт",
sum: "Выгрузка аналитики по участку.",
content: <ReportsContent onExport={onExport} />,
};
case "restrictions": {
const d = adaptRestrictionsDrawer(analysis);
return {
eyebrow: "ОГРАНИЧЕНИЯ",
title: "Ограничения и ЗОУИТ",
sum: d.summary,
content: (
<>
<DSec title="Зоны и линии">
{d.statusRows.map((r, i) => (
<DRow
key={i}
row={{
k: r.label,
field: {
value: r.state,
isReal: !r.state.includes("источник") && !r.state.includes("нет данных"),
caption: r.state,
},
}}
/>
))}
</DSec>
{d.zouitRows.length > 0 && (
<DSec title="Перечень ЗОУИТ">
{d.zouitRows.map((z, i) => (
<DRow
key={i}
row={{ k: z.layer, field: { value: z.sub, isReal: true } }}
/>
))}
</DSec>
)}
<DNote>{d.summary}</DNote>
<DSource>Источник: ИСОГД · ЕГРН · НСПД</DSource>
</>
),
};
}
case "engineering": {
const d = adaptEngineeringDrawer(analysis);
return {
eyebrow: "ИНЖЕНЕРИЯ",
title: "Инженерная обеспеченность",
sum: d.summary,
content: (
<>
{d.hasData ? (
<>
<DSec title="Точки подключения">
{d.rows.map((r, i) => (
<DRow
key={i}
row={{
k: r.label,
field: { value: `${r.name} · ${r.nearest}`, isReal: true },
}}
/>
))}
</DSec>
<DSec title="Близость узлов">
{d.bars.map((b) => (
<DFactor key={b.name} bar={b} />
))}
</DSec>
</>
) : (
<DSec title="Точки подключения">
<div className={styles.emptyState}>
Нет данных по инженерным сетям
</div>
</DSec>
)}
<DNote>{d.summary}</DNote>
<DSource>Источник: ГКХ · сети РСО · НСПД</DSource>
</>
),
};
}
case "market": {
const d = adaptMarketDrawer(analysis);
return {
eyebrow: "РЫНОК",
title: "Рынок района",
sum: d.summary,
content: (
<>
<DSec title="Цены и спрос">
<DRow row={{ k: "Медиана цены", field: d.medianField }} />
<DRow row={{ k: "Тренд 6 мес", field: d.trend.field }} />
<DRow row={{ k: "Скорость поглощения", field: d.speed.field }} />
</DSec>
{d.competitors.length > 0 && (
<DSec title={`Конкуренты · ${d.competitors.length}`}>
<table className={styles.dtable}>
<thead>
<tr>
<th>ЖК</th>
<th>Класс</th>
<th>Кв.</th>
<th>Дист.</th>
</tr>
</thead>
<tbody>
{d.competitors.map((c, i) => (
<tr key={i}>
<td>{c.name}</td>
<td>{c.cls}</td>
<td className={styles.mono}>{c.flats}</td>
<td className={styles.mono}>{c.distance}</td>
</tr>
))}
</tbody>
</table>
</DSec>
)}
<DNote>{d.summary}</DNote>
<DSource>Источник: рыночная аналитика ПТИЦА</DSource>
</>
),
};
}
case "oks": {
const d = adaptOksDrawer();
return {
eyebrow: "ОКС",
title: "Объекты кап. строительства",
sum: d.summary,
content: (
<>
<DSec title="Объекты на участке">
<div className={styles.emptyState}>
Нет данных реестра ОКС (ЕГРН)
</div>
</DSec>
<DNote>{d.summary}</DNote>
<DSource>Источник: ЕГРН (ОКС)</DSource>
</>
),
};
}
}
}