feat(site-finder): add Site Finder v2 cockpit route (ПТИЦА v2)
Port the approved ptica-v2 prototype to a new non-destructive route
/site-finder/analysis/{cad}/v2 — dark/light cockpit shell, real Leaflet
map (reuses PticaMapInner), 6 scan cards, lower/bottom grids, 11 drawers
and grounded chat. All values flow through the existing ptica-adapt layer
(real /analyze data or honest «—»). One beta link added on the analysis page.
This commit is contained in:
parent
520fa55f2b
commit
eb7290dcf0
20 changed files with 4404 additions and 1 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
56
frontend/src/app/site-finder/analysis/[cad]/v2/page.tsx
Normal file
56
frontend/src/app/site-finder/analysis/[cad]/v2/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1893
frontend/src/app/site-finder/analysis/[cad]/v2/v2.module.css
Normal file
1893
frontend/src/app/site-finder/analysis/[cad]/v2/v2.module.css
Normal file
File diff suppressed because it is too large
Load diff
201
frontend/src/components/site-finder/ptica-v2/V2BottomGrid.tsx
Normal file
201
frontend/src/components/site-finder/ptica-v2/V2BottomGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
224
frontend/src/components/site-finder/ptica-v2/V2Chat.tsx
Normal file
224
frontend/src/components/site-finder/ptica-v2/V2Chat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
304
frontend/src/components/site-finder/ptica-v2/V2Cockpit.tsx
Normal file
304
frontend/src/components/site-finder/ptica-v2/V2Cockpit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
frontend/src/components/site-finder/ptica-v2/V2Drawer.tsx
Normal file
49
frontend/src/components/site-finder/ptica-v2/V2Drawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
72
frontend/src/components/site-finder/ptica-v2/V2Gauge.tsx
Normal file
72
frontend/src/components/site-finder/ptica-v2/V2Gauge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
frontend/src/components/site-finder/ptica-v2/V2Icons.tsx
Normal file
53
frontend/src/components/site-finder/ptica-v2/V2Icons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
frontend/src/components/site-finder/ptica-v2/V2LowerGrid.tsx
Normal file
201
frontend/src/components/site-finder/ptica-v2/V2LowerGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/site-finder/ptica-v2/V2Map.tsx
Normal file
57
frontend/src/components/site-finder/ptica-v2/V2Map.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/site-finder/ptica-v2/V2Passport.tsx
Normal file
90
frontend/src/components/site-finder/ptica-v2/V2Passport.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
frontend/src/components/site-finder/ptica-v2/V2Rail.tsx
Normal file
132
frontend/src/components/site-finder/ptica-v2/V2Rail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
frontend/src/components/site-finder/ptica-v2/V2RightColumn.tsx
Normal file
146
frontend/src/components/site-finder/ptica-v2/V2RightColumn.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
frontend/src/components/site-finder/ptica-v2/V2RiskCard.tsx
Normal file
62
frontend/src/components/site-finder/ptica-v2/V2RiskCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
frontend/src/components/site-finder/ptica-v2/V2ScanCard.tsx
Normal file
116
frontend/src/components/site-finder/ptica-v2/V2ScanCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
frontend/src/components/site-finder/ptica-v2/V2Topbar.tsx
Normal file
104
frontend/src/components/site-finder/ptica-v2/V2Topbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue