From 95f93da83bb6075ca747e14e579fa04b8636764b Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 20 Jun 2026 16:16:39 +0500 Subject: [PATCH] feat(ptica): right-slider detail drawer system + 17 drawers wired (PR#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PticaDrawer: controlled right slider (480px / full-screen <768), scrim+Esc close, focus-on-open, body-scroll-lock (restored on close/unmount), dialog a11y. Deep-link ?drawer= via useSearchParams + shallow router.replace (no render loop; unknown keys ignored). Enables the «Подробнее» buttons on scan + hero cards. 17 drawer contents (passport/buildability/urban/restrictions/engineering/ market[4 sub-tabs]/economy/risks/georisks/oks/potential/product/finance/legal/ environment/insolation/future3d) built from ParcelAnalysis + forecast product_tz. Many are REAL: environment (noise/air_quality/wind/weather), georisks (geology/ hydrology/geotech_risk), market (competitors/market_trend/velocity/pipeline), restrictions (zouit/red_lines/zoning), engineering, passport. Absent fields (КСИТ/ОКС/финмодель/insolation) show honest «—»/«скоро» via ptica-adapt. All field paths verified against types. Dark theme scoped. TS strict, no any. Follow-up: drawer focus-trap + focus-restore for full modal a11y. --- .../analysis/[cad]/ptica/PticaPageContent.tsx | 82 +- .../analysis/[cad]/ptica/ptica.module.css | 253 +++++ .../site-finder/ptica/DevelopmentScan.tsx | 54 +- .../site-finder/ptica/InvestScoreBlock.tsx | 17 +- .../site-finder/ptica/ParcelPassportCard.tsx | 12 +- .../site-finder/ptica/PticaHero.tsx | 25 +- .../components/site-finder/ptica/ScanCard.tsx | 25 +- .../ptica/drawers/DrawerContent.tsx | 853 ++++++++++++++++ .../ptica/drawers/DrawerPrimitives.tsx | 307 ++++++ .../site-finder/ptica/drawers/PticaDrawer.tsx | 95 ++ .../site-finder/ptica/drawers/drawer-keys.ts | 41 + .../ptica/drawers/drawer-registry.tsx | 151 +++ .../site-finder/ptica/ptica-adapt.ts | 951 +++++++++++++++++- 13 files changed, 2837 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx create mode 100644 frontend/src/components/site-finder/ptica/drawers/DrawerPrimitives.tsx create mode 100644 frontend/src/components/site-finder/ptica/drawers/PticaDrawer.tsx create mode 100644 frontend/src/components/site-finder/ptica/drawers/drawer-keys.ts create mode 100644 frontend/src/components/site-finder/ptica/drawers/drawer-registry.tsx diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx index 0a2c00b1..684b1d61 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx @@ -1,20 +1,27 @@ "use client"; /** - * PticaPageContent — client root of the ПТИЦА cockpit (INCREMENT 1). + * PticaPageContent — client root of the ПТИЦА cockpit. * * Wires the SAME /analyze data as the existing light analysis page via * useParcelAnalyzeQuery, narrows the response to ParcelAnalysis (the sanctioned * `as unknown as` pattern — see AnalysisPageContent.tsx:111), and composes the - * dark operator-terminal shell. Only the 'analysis' tab renders content in PR#1; - * Scenarios / Reports / Compare show an honest "В разработке" panel. + * dark operator-terminal shell. + * + * PR#4 — DETAIL DRAWER system: this root owns the open-drawer state + * (`openDrawer: DrawerKey | null`), threads an `onOpenDrawer(key)` callback down + * the tree (hero cards + scan cards), and renders the matching drawer content + * inside a single controlled . Deep-link: `?drawer=` opens a + * drawer on mount; opening/closing updates the URL query via `router.replace` + * (shallow, no full navigation) so drawers are linkable / back-button friendly. * * The root carries BOTH `.pticaRoot` and `data-theme="dark"` so the scoped dark * tokens apply here and never leak to the light pages. */ -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import styles from "./ptica.module.css"; import { @@ -29,6 +36,12 @@ import { DevelopmentScan } from "@/components/site-finder/ptica/DevelopmentScan" import { PticaScenarios } from "@/components/site-finder/ptica/scenarios/PticaScenarios"; import { PticaReports } from "@/components/site-finder/ptica/reports/PticaReports"; import { PticaCompare } from "@/components/site-finder/ptica/compare/PticaCompare"; +import { PticaDrawer } from "@/components/site-finder/ptica/drawers/PticaDrawer"; +import { getDrawerEntry } from "@/components/site-finder/ptica/drawers/drawer-registry"; +import { + asDrawerKey, + type DrawerKey, +} from "@/components/site-finder/ptica/drawers/drawer-keys"; interface Props { cad: string; @@ -45,6 +58,43 @@ export function PticaPageContent({ cad }: Props) { const forecastReport = forecastData?.status === "ready" ? forecastData.report : undefined; + // ── Drawer open-state + deep-link (?drawer=) ────────────────────────── + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [openDrawer, setOpenDrawer] = useState(null); + + // On mount / URL change, sync the open drawer from the `?drawer=` query. + const drawerParam = searchParams.get("drawer"); + useEffect(() => { + setOpenDrawer(asDrawerKey(drawerParam)); + }, [drawerParam]); + + // Update the URL query shallowly (no full navigation) when opening/closing. + const syncUrl = useCallback( + (key: DrawerKey | null) => { + const params = new URLSearchParams(searchParams.toString()); + if (key) params.set("drawer", key); + else params.delete("drawer"); + const qs = params.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + }, + [router, pathname, searchParams], + ); + + const handleOpenDrawer = useCallback( + (key: DrawerKey) => { + setOpenDrawer(key); + syncUrl(key); + }, + [syncUrl], + ); + + const handleCloseDrawer = useCallback(() => { + setOpenDrawer(null); + syncUrl(null); + }, [syncUrl]); + // ── Loading ──────────────────────────────────────────────────────────────── if (isLoading) { return ( @@ -78,13 +128,24 @@ export function PticaPageContent({ cad }: Props) { // the same /analyze endpoint. Sanctioned pattern (AnalysisPageContent.tsx:111). const analysis = data as unknown as ParcelAnalysis; + const drawerEntry = openDrawer + ? getDrawerEntry(openDrawer, analysis, forecastReport) + : null; + return (
{tab === "analysis" && ( <> - - + + )} {tab === "scenarios" && ( @@ -97,6 +158,15 @@ export function PticaPageContent({ cad }: Props) { {tab === "reports" && } {tab === "compare" && } + + + {drawerEntry?.content} +
); } diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css index 6e2eeec2..30ba3dab 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css @@ -1394,6 +1394,254 @@ font-size: 10px; } +/* ===================== DETAIL DRAWER (PR#4) ===================== */ +.drawerScrim { + position: fixed; + inset: 0; + background: rgba(2, 8, 12, 0.62); + backdrop-filter: blur(2px); + opacity: 0; + visibility: hidden; + transition: + opacity 0.18s ease, + visibility 0.18s ease; + z-index: 60; +} +.drawerScrimOpen { + opacity: 1; + visibility: visible; +} +.drawer { + position: fixed; + top: 0; + right: 0; + height: 100vh; + width: 480px; + max-width: 100vw; + background: var(--surface-strong); + border-left: var(--line) solid var(--border-strong); + box-shadow: var(--shadow); + transform: translateX(100%); + transition: transform 0.18s ease; + z-index: 61; + display: flex; + flex-direction: column; +} +.drawerOpen { + transform: translateX(0); +} +.drawerHead { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 16px 18px; + border-bottom: var(--line) solid var(--border); + flex-shrink: 0; +} +.drawerTitle { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--text-strong); +} +.drawerSub { + margin-top: 3px; + font-size: 10px; + color: var(--text-soft); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.drawerClose { + flex-shrink: 0; + display: grid; + place-items: center; + width: 30px; + height: 30px; + border: var(--line) solid var(--border); + border-radius: var(--radius-xs); + background: transparent; + color: var(--text-muted); +} +.drawerClose:hover { + color: var(--text-strong); + border-color: var(--border-strong); +} +.drawerClose:focus-visible { + outline: var(--line-strong) solid var(--accent-cyan); + outline-offset: 1px; +} +.drawerBody { + flex: 1; + overflow-y: auto; + padding: 16px 18px 32px; +} +.drawerSection { + margin-bottom: 18px; +} +.drawerSection:last-child { + margin-bottom: 0; +} +.drawerH4 { + margin: 0 0 9px; + font-size: 9.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-soft); +} +.summaryBox { + padding: 11px 12px; + background: var(--surface-2); + border: var(--line) solid var(--border-faint); + border-radius: var(--radius-sm); + font-size: 11.5px; + line-height: 1.55; + color: var(--text); +} +.statusRow { + display: flex; + align-items: center; + gap: 9px; + padding: 4px 0; + font-size: 11px; +} +.drDot { + flex-shrink: 0; + width: 7px; + height: 7px; + border-radius: 50%; +} +.drDotOk { + background: var(--accent-green); +} +.drDotWarn { + background: var(--accent-yellow); +} +.drDotBad { + background: var(--accent-red); +} +.drDotMuted { + background: var(--text-soft); +} +.drLabel { + flex: 1; + color: var(--text-muted); +} +.drState { + text-align: right; + font-weight: 600; +} +.drStateOk { + color: var(--accent-green); +} +.drStateWarn { + color: var(--accent-yellow); +} +.drStateBad { + color: var(--accent-red); +} +.drStateMuted { + color: var(--text-soft); + font-weight: 500; +} +.drawerActions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.drawerGaugeRow { + display: flex; + justify-content: center; + margin-bottom: 12px; +} +.drawerNote { + margin: 0 0 8px; + font-size: 11px; + line-height: 1.5; + color: var(--text-muted); +} +.drawerLinks { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} +.drawerLink { + font-size: 10px; + color: var(--accent-cyan); + text-decoration: none; +} +.drawerLink:hover { + text-decoration: underline; +} +.soonCard { + display: flex; + flex-direction: column; + align-items: center; + gap: 9px; + text-align: center; + padding: 22px 16px; + border-style: dashed; +} +.soonCard p { + margin: 0; + font-size: 11px; + line-height: 1.5; + color: var(--text-soft); + max-width: 320px; +} +.roadmap { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-soft); +} + +/* drawer sub-tabs (.dtabs / .dtab-panel) */ +.dtabs { + display: flex; + gap: 4px; + margin-bottom: 11px; + border-bottom: var(--line) solid var(--border-faint); +} +.dtabs button { + padding: 6px 10px; + background: transparent; + border: none; + border-bottom: var(--line-strong) solid transparent; + color: var(--text-soft); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.dtabs button:hover { + color: var(--text-muted); +} +.dtabActive { + color: var(--text-strong) !important; + border-bottom-color: var(--accent-cyan) !important; +} +.dtabPanel { + display: grid; + gap: 2px; +} + +/* gauge-as-button (hero Buildability) */ +.gaugeButton { + display: block; + width: 100%; + background: transparent; + border: none; + padding: 0; + cursor: pointer; +} +.gaugeButton:focus-visible { + outline: var(--line-strong) solid var(--accent-cyan); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + /* ===================== RESPONSIVE ===================== */ @media (max-width: 1500px) { .hero { @@ -1438,3 +1686,8 @@ grid-template-columns: 1fr; } } +@media (max-width: 768px) { + .drawer { + width: 100vw; + } +} diff --git a/frontend/src/components/site-finder/ptica/DevelopmentScan.tsx b/frontend/src/components/site-finder/ptica/DevelopmentScan.tsx index 788172bb..f7b2f00a 100644 --- a/frontend/src/components/site-finder/ptica/DevelopmentScan.tsx +++ b/frontend/src/components/site-finder/ptica/DevelopmentScan.tsx @@ -4,6 +4,9 @@ * DevelopmentScan — "Development Scan" section: a responsive grid of ScanCards * (Градостроительство / Ограничения / Инженерия / Рынок / Экономика / Риски / * Среда + ОКС). Every card's real-vs-placeholder content comes from the adapter. + * + * Each card maps to its drawer key and opens the matching detail drawer via the + * `onOpenDrawer` callback threaded down from PticaPageContent. */ import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; @@ -20,12 +23,14 @@ import { adaptOksCard, adaptRiskGauge, } from "@/components/site-finder/ptica/ptica-adapt"; +import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys"; interface Props { analysis: ParcelAnalysis; + onOpenDrawer: (key: DrawerKey) => void; } -export function DevelopmentScan({ analysis }: Props) { +export function DevelopmentScan({ analysis, onOpenDrawer }: Props) { const riskGauge = adaptRiskGauge(analysis); return ( @@ -34,12 +39,32 @@ export function DevelopmentScan({ analysis }: Props) { Development Scan · градостроительный анализ
- - - - + + + +
- +
+
- - + +
); diff --git a/frontend/src/components/site-finder/ptica/InvestScoreBlock.tsx b/frontend/src/components/site-finder/ptica/InvestScoreBlock.tsx index 3a2f2504..5727b917 100644 --- a/frontend/src/components/site-finder/ptica/InvestScoreBlock.tsx +++ b/frontend/src/components/site-finder/ptica/InvestScoreBlock.tsx @@ -11,14 +11,20 @@ import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; import type { ParcelAnalysis } from "@/types/site-finder"; import type { ForecastReport } from "@/types/forecast"; import { adaptInvestScore } from "@/components/site-finder/ptica/ptica-adapt"; +import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys"; interface Props { analysis: ParcelAnalysis; /** §22 forecast report — when ready, surfaces the REAL invest-score / buy-signal. */ forecastReport?: ForecastReport; + onOpenDrawer: (key: DrawerKey) => void; } -export function InvestScoreBlock({ analysis, forecastReport }: Props) { +export function InvestScoreBlock({ + analysis, + forecastReport, + onOpenDrawer, +}: Props) { const inv = adaptInvestScore(analysis, forecastReport); return ( @@ -63,6 +69,15 @@ export function InvestScoreBlock({ analysis, forecastReport }: Props) { {inv.buySignal.isReal ? inv.buySignal.value : "после прогноза"} + + ); } diff --git a/frontend/src/components/site-finder/ptica/ParcelPassportCard.tsx b/frontend/src/components/site-finder/ptica/ParcelPassportCard.tsx index c4a3de52..62a02ec6 100644 --- a/frontend/src/components/site-finder/ptica/ParcelPassportCard.tsx +++ b/frontend/src/components/site-finder/ptica/ParcelPassportCard.tsx @@ -13,9 +13,11 @@ import type { PticaField, PticaPassport, } from "@/components/site-finder/ptica/ptica-adapt"; +import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys"; interface Props { analysis: ParcelAnalysis; + onOpenDrawer: (key: DrawerKey) => void; } function KvCell({ @@ -42,13 +44,21 @@ function KvCell({ ); } -export function ParcelPassportCard({ analysis }: Props) { +export function ParcelPassportCard({ analysis, onOpenDrawer }: Props) { const p: PticaPassport = adaptPassport(analysis); return (

Паспорт участка

+
diff --git a/frontend/src/components/site-finder/ptica/PticaHero.tsx b/frontend/src/components/site-finder/ptica/PticaHero.tsx index 54f03b1f..71d8c032 100644 --- a/frontend/src/components/site-finder/ptica/PticaHero.tsx +++ b/frontend/src/components/site-finder/ptica/PticaHero.tsx @@ -3,6 +3,10 @@ /** * PticaHero — top of the cockpit: map + passport KV-grid + Buildability gauge + * invest-score block, in the prototype's hero grid. + * + * The passport card, the Buildability gauge and the invest/potential block each + * open their detail drawer via the `onOpenDrawer` callback threaded down from + * PticaPageContent (passport / buildability / potential keys). */ import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; @@ -13,25 +17,38 @@ import { ParcelPassportCard } from "@/components/site-finder/ptica/ParcelPasspor import { BuildabilityGauge } from "@/components/site-finder/ptica/BuildabilityGauge"; import { InvestScoreBlock } from "@/components/site-finder/ptica/InvestScoreBlock"; import { adaptBuildabilityGauge } from "@/components/site-finder/ptica/ptica-adapt"; +import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys"; interface Props { analysis: ParcelAnalysis; /** §22 forecast report — threaded into the invest-score block once ready. */ forecastReport?: ForecastReport; + onOpenDrawer: (key: DrawerKey) => void; } -export function PticaHero({ analysis, forecastReport }: Props) { +export function PticaHero({ analysis, forecastReport, onOpenDrawer }: Props) { const buildGauge = adaptBuildabilityGauge(analysis); return (
- +
- - + +
); diff --git a/frontend/src/components/site-finder/ptica/ScanCard.tsx b/frontend/src/components/site-finder/ptica/ScanCard.tsx index a63f234b..83e348ca 100644 --- a/frontend/src/components/site-finder/ptica/ScanCard.tsx +++ b/frontend/src/components/site-finder/ptica/ScanCard.tsx @@ -2,22 +2,28 @@ /** * ScanCard — generic dark cockpit card: title (+ optional badge), metric rows, - * and a "Подробнее" button that is RENDERED but disabled in INCREMENT 1 (drill- - * down detail ships in a later release). + * and a "Подробнее" button that opens the card's detail drawer. * - * Each row's value flows from a {value, isReal, caption} view-model: placeholder + * When `drawerKey` + `onOpen` are provided the button is ENABLED and opens the + * matching drawer; without them it stays disabled (no drill-down wired). Each + * row's value flows from a {value, isReal, caption} view-model: placeholder * fields are rendered muted with their caption so they never read as live data. */ import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; import type { PticaScanCard } from "@/components/site-finder/ptica/ptica-adapt"; +import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys"; interface Props { card: PticaScanCard; + /** When set (with onOpen) the "Подробнее" button opens this drawer. */ + drawerKey?: DrawerKey; + onOpen?: (key: DrawerKey) => void; } -export function ScanCard({ card }: Props) { +export function ScanCard({ card, drawerKey, onOpen }: Props) { const { title, badge, rows, emptyState } = card; + const canOpen = Boolean(drawerKey && onOpen); return (
@@ -50,9 +56,14 @@ export function ScanCard({ card }: Props) { diff --git a/frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx b/frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx new file mode 100644 index 00000000..ab3fb60d --- /dev/null +++ b/frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx @@ -0,0 +1,853 @@ +"use client"; + +/** + * DrawerContent — per-key detail-drawer bodies, built from the typed drawer + * view-models in ptica-adapt.ts (REAL data first, honest «—» placeholders for + * absent fields). Each export renders the prototype's + * summary → table/sub-tabs → source-line → optional export structure. + * + * Real-vs-placeholder lives entirely in the adapters; these components only + * present the view-models, so a placeholder field is always styled muted with + * its caption rather than shown as a live number. + */ + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; +import type { ParcelAnalysis } from "@/types/site-finder"; +import type { ForecastReport } from "@/types/forecast"; +import type { + DrawerKvRow, + PticaField, + PticaFactorBar, +} from "@/components/site-finder/ptica/ptica-adapt"; +import { + adaptPassportDrawer, + adaptBuildabilityDrawer, + adaptUrbanDrawer, + adaptRestrictionsDrawer, + adaptLegalDrawer, + adaptEngineeringDrawer, + adaptMarketDrawer, + adaptEnvironmentDrawer, + adaptGeorisksDrawer, + adaptRisksDrawer, + adaptProductDrawer, + adaptPotentialDrawer, + adaptEconomyDrawer, + adaptFinanceDrawer, + adaptOksDrawer, +} from "@/components/site-finder/ptica/ptica-adapt"; +import { BuildabilityGauge } from "@/components/site-finder/ptica/BuildabilityGauge"; +import { + DrawerSection, + SummaryBox, + DKvRow, + StatusRow, + DTable, + HBars, + DrawerTabs, + ConfPill, + SourceLine, + DrawerActions, + SoonCard, +} from "@/components/site-finder/ptica/drawers/DrawerPrimitives"; + +const SRC_UPDATED = "источник: /analyze"; + +// ── shared helpers ──────────────────────────────────────────────────────────── + +/** Render a kv field value: muted span + caption tooltip for placeholders. */ +function FieldValue({ field }: { field: PticaField }) { + if (field.isReal) return <>{field.value}; + return ( + + {field.value} + + ); +} + +function KvRows({ rows }: { rows: DrawerKvRow[] }) { + return ( + <> + {rows.map((r) => ( + } + muted={!r.field.isReal} + /> + ))} + + ); +} + +function factorBars(factors: PticaFactorBar[]) { + return factors.map((f) => ({ + name: f.name, + pct: f.pct, + value: f.value, + tone: f.tone, + })); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// passport +// ══════════════════════════════════════════════════════════════════════════════ + +export function PassportContent({ analysis }: { analysis: ParcelAnalysis }) { + const d = adaptPassportDrawer(analysis); + return ( + <> + + {d.summary} + + + + + + + + 0 ? "warn" : "ok"} + /> + {d.zouitOverlaps.length > 0 && ( + ({ cells: [z.name, z.sub] }))} + /> + )} + + + + + + + + ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// buildability +// ══════════════════════════════════════════════════════════════════════════════ + +export function BuildabilityContent({ + analysis, +}: { + analysis: ParcelAnalysis; +}) { + const d = adaptBuildabilityDrawer(analysis); + return ( + <> + +
+ +
+ {d.summary} +
+ + + + ({ cells: [f.name, f.value, f.reason] }))} + /> + + + + + {d.penalties.length > 0 && ( + ({ cells: [p.label, p.impact] }))} + /> + )} + + + + + ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// urban +// ══════════════════════════════════════════════════════════════════════════════ + +export function UrbanContent({ analysis }: { analysis: ParcelAnalysis }) { + const d = adaptUrbanDrawer(analysis); + return ( + <> + + {d.summary} + + + + + + + + } + tone={d.zoneCode.isReal ? "ok" : "muted"} + /> + } + tone={d.zoneName.isReal ? "ok" : "muted"} + /> + + + + + ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// restrictions +// ══════════════════════════════════════════════════════════════════════════════ + +export function RestrictionsContent({ + analysis, +}: { + analysis: ParcelAnalysis; +}) { + const d = adaptRestrictionsDrawer(analysis); + return ( + <> + + {d.summary} + + + + {d.statusRows.map((r) => ( + + ))} + + + {d.zouitRows.length > 0 && ( + + ({ cells: [z.layer, z.sub, z.reg] }))} + /> + + )} + + + + ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// legal +// ══════════════════════════════════════════════════════════════════════════════ + +export function LegalContent({ analysis }: { analysis: ParcelAnalysis }) { + const d = adaptLegalDrawer(analysis); + return ( + <> + + {d.summary} + + + + + + + + {d.restrictions.map((r) => ( + + ))} + + + + + + ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// engineering +// ══════════════════════════════════════════════════════════════════════════════ + +export function EngineeringContent({ analysis }: { analysis: ParcelAnalysis }) { + const d = adaptEngineeringDrawer(analysis); + return ( + <> + + {d.summary} + + + {d.hasData ? ( + <> + + ({ + cells: [r.label, r.nearest, r.name, r.count], + }))} + /> + + + + + + ) : ( + +
+ Нет данных по инженерным сетям +
+
+ )} + + + {d.hasData && ( + + + + )} + + ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// market (4 sub-tabs) +// ══════════════════════════════════════════════════════════════════════════════ + +export function MarketContent({ analysis }: { analysis: ParcelAnalysis }) { + const d = adaptMarketDrawer(analysis); + + const compPanel = ( + <> + {d.competitors.length > 0 ? ( + ({ + cells: [c.name, c.dev, c.cls, c.flats, c.distance, c.status], + }))} + /> + ) : ( +
Конкуренты не найдены
+ )} + } + muted={!d.medianField.isReal} + /> + + ); + + const planPanel = ( + <> + {d.plan.note} + {d.plan.available && d.plan.pipelineByClass.length > 0 && ( + ({ + cells: [p.cls, String(p.count)], + }))} + /> + )} + + ); + + const speedPanel = d.speed.available ? ( + <> + + + + + + ) : ( +
Скорость — после прогноза
+ ); + + const trendPanel = d.trend.available ? ( + <> + } /> + + + + + ) : ( +
Тренд — после прогноза
+ ); + + return ( + <> + + {d.summary} + + + + + + + + {d.competitors.length > 0 && ( + + + + )} + + ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// environment (Среда — 4 sub-tabs) +// ══════════════════════════════════════════════════════════════════════════════ + +export function EnvironmentContent({ analysis }: { analysis: ParcelAnalysis }) { + const d = adaptEnvironmentDrawer(analysis); + + const noisePanel = ( + <> + + {d.noise.sources.length > 0 && ( + ({ cells: [s.name, s.db, s.dist] }))} + /> + )} + + ); + + return ( + <> + + {d.summary} + + + + , + }, + { + id: "wind", + label: "Ветер", + content: , + }, + { + id: "weather", + label: "Погода", + content: , + }, + ]} + /> + + + + + ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// georisks (3 sub-tabs) +// ══════════════════════════════════════════════════════════════════════════════ + +export function GeorisksContent({ analysis }: { analysis: ParcelAnalysis }) { + const d = adaptGeorisksDrawer(analysis); + + const geologyPanel = ( + <> + + {d.geology.links.length > 0 && ( +
+ {d.geology.links.map((l) => ( + + {l.label} → + + ))} +
+ )} + + ); + + return ( + <> + + {d.summary} + + + + , + }, + { + id: "geotech", + label: "Геотехника", + content: , + }, + ]} + /> + + + + + ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// risks (composite, derived) +// ══════════════════════════════════════════════════════════════════════════════ + +export function RisksContent({ analysis }: { analysis: ParcelAnalysis }) { + const d = adaptRisksDrawer(analysis); + return ( + <> + +
+ +
+ {d.summary} +
+ + + + ({ + cells: [f.name, f.value, f.reason], + }))} + /> + + + {(d.blockers.length > 0 || d.warnings.length > 0) && ( + + {d.blockers.map((b) => ( + + ))} + {d.warnings.map((w) => ( + + ))} + + )} + + + + ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// product (§22 product_tz) +// ══════════════════════════════════════════════════════════════════════════════ + +export function ProductContent({ + forecastReport, +}: { + forecastReport?: ForecastReport; +}) { + const d = adaptProductDrawer(forecastReport); + + if (!d.available) { + return ( + <> + + {d.summary} + + +
После прогноза (Сценарии)
+
+ + ); + } + + return ( + <> + + {d.summary} + + + + {d.objClass.value} + ) : ( + + ) + } + muted={!d.objClass.isReal} + /> + + + {d.mix.length > 0 && ( + + ({ + name: m.bucket, + pct: m.pct, + value: m.signal, + tone: "neutral", + }))} + /> + + )} + + {d.usp.length > 0 && ( + + ({ cells: [u.text, u.cls] }))} + /> + + )} + + {d.reasons.length > 0 && ( + + {d.reasons.map((r, i) => ( +

+ {r} +

+ ))} +
+ )} + + + + ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// potential +// ══════════════════════════════════════════════════════════════════════════════ + +export function PotentialContent({ analysis }: { analysis: ParcelAnalysis }) { + const d = adaptPotentialDrawer(analysis); + return ( + <> + + {d.summary} + + + + + + + ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// economy / finance +// ══════════════════════════════════════════════════════════════════════════════ + +export function EconomyContent() { + const d = adaptEconomyDrawer(); + return ( + <> + + {d.summary} + + + + + + + ); +} + +export function FinanceContent() { + const d = adaptFinanceDrawer(); + return ( + <> + + {d.summary} + + + + + + + ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// oks +// ══════════════════════════════════════════════════════════════════════════════ + +export function OksContent() { + const d = adaptOksDrawer(); + return ( + <> + + {d.summary} + + +
Нет данных
+
+ + ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// insolation / future3d — СКОРО (3D module lands in PR#5) +// ══════════════════════════════════════════════════════════════════════════════ + +export function InsolationContent() { + return ( + <> + + + + + + + + + + ); +} + +export function Future3dContent() { + return ( + <> + + + + + + + + + + ); +} diff --git a/frontend/src/components/site-finder/ptica/drawers/DrawerPrimitives.tsx b/frontend/src/components/site-finder/ptica/drawers/DrawerPrimitives.tsx new file mode 100644 index 00000000..365dc075 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/drawers/DrawerPrimitives.tsx @@ -0,0 +1,307 @@ +"use client"; + +/** + * DrawerPrimitives — typed React port of the prototype's drawer markup vocabulary + * (`.drawer-section` → `.summary-box` → table / kvrows / status-rows / hbars → + * `.source-line` → `.drawer-actions`). Pure presentational building blocks the + * per-key drawer content modules compose; no data logic lives here. + * + * Sub-tabs (`.dtabs` / `.dtab-panel`) are a self-contained client component + * (DrawerTabs) that manages the active tab with local useState — the cockpit's + * market / environment / georisks drawers reuse it. + */ + +import { useId, useState } from "react"; + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; + +// ── Section + summary ───────────────────────────────────────────────────────── + +export function DrawerSection({ + heading, + children, +}: { + heading?: string; + children: React.ReactNode; +}) { + return ( +
+ {heading &&

{heading}

} + {children} +
+ ); +} + +export function SummaryBox({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +// ── Key/value rows ──────────────────────────────────────────────────────────── + +export function DKvRow({ + k, + v, + muted, +}: { + k: React.ReactNode; + v: React.ReactNode; + muted?: boolean; +}) { + return ( +
+ {k} + {v} +
+ ); +} + +// ── Status row (dot + label + state pill) ───────────────────────────────────── + +export type DrawerTone = "ok" | "warn" | "bad" | "muted"; + +export function StatusRow({ + label, + state, + tone = "muted", +}: { + label: React.ReactNode; + state: React.ReactNode; + tone?: DrawerTone; +}) { + const dotClass = + tone === "ok" + ? styles.drDotOk + : tone === "warn" + ? styles.drDotWarn + : tone === "bad" + ? styles.drDotBad + : styles.drDotMuted; + const stateClass = + tone === "ok" + ? styles.drStateOk + : tone === "warn" + ? styles.drStateWarn + : tone === "bad" + ? styles.drStateBad + : styles.drStateMuted; + return ( +
+
+ ); +} + +// ── Data table ──────────────────────────────────────────────────────────────── + +export interface DTableColumn { + header: string; + /** true → right-aligned mono cell (numbers). */ + num?: boolean; +} + +export interface DTableRow { + cells: React.ReactNode[]; + /** highlight (e.g. totals / target). */ + emphasis?: boolean; +} + +export function DTable({ + columns, + rows, +}: { + columns: DTableColumn[]; + rows: DTableRow[]; +}) { + return ( + + + + {columns.map((c, i) => ( + + ))} + + + + {rows.map((r, ri) => ( + + {r.cells.map((cell, ci) => ( + + ))} + + ))} + +
+ {c.header} +
+ {cell} +
+ ); +} + +// ── Horizontal bars (квартирография / contributions) ────────────────────────── + +export interface HBarItem { + name: string; + /** 0..100 fill width. */ + pct: number; + value: React.ReactNode; + tone?: "good" | "bad" | "neutral"; +} + +export function HBars({ items }: { items: HBarItem[] }) { + return ( +
+ {items.map((b, i) => { + const fillClass = + b.tone === "good" + ? styles.hbarFillGood + : b.tone === "bad" + ? styles.hbarFillBad + : styles.hbarFillNeutral; + return ( +
+ {b.name} + + + + {b.value} +
+ ); + })} +
+ ); +} + +// ── Confidence pill / tag ───────────────────────────────────────────────────── + +export function ConfPill({ + level, + children, +}: { + level: "high" | "medium" | "low"; + children: React.ReactNode; +}) { + const cls = + level === "high" + ? styles.confPillHigh + : level === "medium" + ? styles.confPillMedium + : styles.confPillLow; + return {children}; +} + +export function Tag({ children }: { children: React.ReactNode }) { + return {children}; +} + +// ── Source line + export actions ────────────────────────────────────────────── + +export function SourceLine({ + source, + updated, +}: { + source: string; + updated?: string; +}) { + return ( +
+ {source} + {updated && {updated}} +
+ ); +} + +/** + * DrawerActions — light export/copy buttons (kept inert: the cockpit's drawer + * exports are not wired to an endpoint yet, so these are disabled placeholders + * matching the prototype's `.drawer-actions` rather than fake-firing a download). + */ +export function DrawerActions({ labels }: { labels: string[] }) { + return ( +
+ {labels.map((l) => ( + + ))} +
+ ); +} + +// ── Placeholder card (СКОРО) ────────────────────────────────────────────────── + +export function SoonCard({ + text, + roadmap, +}: { + text: string; + roadmap?: string; +}) { + return ( +
+
СКОРО
+

{text}

+ {roadmap &&
{roadmap}
} +
+ ); +} + +// ── Sub-tabs (.dtabs / .dtab-panel) ─────────────────────────────────────────── + +export interface DrawerTab { + id: string; + label: string; + content: React.ReactNode; +} + +export function DrawerTabs({ tabs }: { tabs: DrawerTab[] }) { + const [active, setActive] = useState(tabs[0]?.id ?? ""); + const groupId = useId(); + return ( + <> +
+ {tabs.map((t) => ( + + ))} +
+ {tabs.map((t) => ( + + ))} + + ); +} diff --git a/frontend/src/components/site-finder/ptica/drawers/PticaDrawer.tsx b/frontend/src/components/site-finder/ptica/drawers/PticaDrawer.tsx new file mode 100644 index 00000000..c101e601 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/drawers/PticaDrawer.tsx @@ -0,0 +1,95 @@ +"use client"; + +/** + * PticaDrawer — controlled right-slider detail drawer (CoStar drill-down pattern, + * per ui-conventions.md: 480px desktop, full-screen on phone). + * + * Behaviour ported from the prototype `openDrawer`/`closeDrawer` (app.js): + * - scrim click closes, + * - Escape closes, + * - the close button is focused on open (a11y), + * - body scroll is locked while open. + * + * `role="dialog"` + `aria-modal` + `aria-label` make it a proper modal surface. + * The component is fully controlled — `open` drives mount/visibility, the host + * (PticaPageContent) owns the open-key state and URL deep-link. + */ + +import { useEffect, useRef } from "react"; + +import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; + +interface Props { + open: boolean; + title: string; + /** Sub-title under the drawer title (e.g. "Полные данные ЕГРН / НСПД"). */ + sub?: string; + onClose: () => void; + children: React.ReactNode; +} + +export function PticaDrawer({ open, title, sub, onClose, children }: Props) { + const closeRef = useRef(null); + + // Esc-to-close + body-scroll-lock + focus the close button on open. + useEffect(() => { + if (!open) return; + + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", onKeyDown); + + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + // Focus the close button so keyboard users land inside the dialog. + closeRef.current?.focus(); + + return () => { + document.removeEventListener("keydown", onKeyDown); + document.body.style.overflow = prevOverflow; + }; + }, [open, onClose]); + + return ( + <> +