diff --git a/frontend/package.json b/frontend/package.json index f79f586c..ad2b5521 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "echarts-for-react": "^3.0.6", "leaflet": "^1.9.4", "leaflet-draw": "^1.0.4", + "lucide-react": "^0.511.0", "next": "^15.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/frontend/src/app/site-finder/analysis/[cad]/page.tsx b/frontend/src/app/site-finder/analysis/[cad]/page.tsx index 0573e29a..6e1d6d47 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/page.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/page.tsx @@ -1,72 +1,13 @@ "use client"; import { use } from "react"; -import Link from "next/link"; import { LayoutDashboard } from "lucide-react"; -// ── Placeholder components ───────────────────────────────────────────────── -// Replaced in Wave 2–4 with real sections +import { AnalysisSidebar } from "@/components/site-finder/analysis/AnalysisSidebar"; +import { AnalysisBreadcrumb } from "@/components/site-finder/analysis/AnalysisBreadcrumb"; +import { UserAvatar } from "@/components/site-finder/analysis/UserAvatar"; -function SidebarPlaceholder() { - return ( - - ); -} +// ── Section placeholder (Wave 3–4 refactor will replace these) ───────────────── interface SectionPlaceholderProps { id: string; @@ -145,7 +86,7 @@ export default function AnalysisPage({ params }: PageProps) { flexDirection: "column", }} > - {/* Header / breadcrumb */} + {/* ── Header ─────────────────────────────────────────────────────────── */}
- {/* TODO A4: replace with */} - - ← К карте - - · - - {cad} - - · - - TODO A4: район · площадь · анализ свежий N мин - - {/* TODO A4: справа */} + + + {/* Push avatar to the right */} +
+ +
- {/* Body: sidebar + scrollable sections */} + {/* ── Body: sidebar + scrollable sections ───────────────────────────── */}
- {/* TODO A4: replace with */} - + {/* Main scrollable content */}
- {/* TODO A5: Section 1 — HeadlineBar + 4 KPI + mini-map + EgrnPropertyTable + PoiList2Gis */} + {/* Section 1 — TODO A5 */} - {/* TODO A6: Section 2 — NspdEngineeringNearbyBlock */} + {/* Section 2 — TODO A6 */} - {/* TODO A7/A8/A9: Section 3 — sub-sections 3.1 / 3.2 / 3.3 + CompetitorTable */} + {/* Section 3 — TODO A7/A8/A9 */} - {/* TODO A10: Section 4 — ScoreBreakdownPanel + GateVerdictBanner + geology/hydro/risk */} + {/* Sub-section anchor targets (invisible) for scrollspy / direct links */} +
diff --git a/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx b/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx new file mode 100644 index 00000000..b72e33c9 --- /dev/null +++ b/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx @@ -0,0 +1,82 @@ +"use client"; + +import Link from "next/link"; +import { ChevronRight } from "lucide-react"; + +interface AnalysisBreadcrumbProps { + cadNum: string; +} + +export function AnalysisBreadcrumb({ cadNum }: AnalysisBreadcrumbProps) { + return ( + + ); +} diff --git a/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx b/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx new file mode 100644 index 00000000..50f3044f --- /dev/null +++ b/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx @@ -0,0 +1,254 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import { ExternalLink } from "lucide-react"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface SubSection { + id: string; + label: string; +} + +interface NavSection { + id: string; + label: string; + sub?: SubSection[]; +} + +// ── Config ──────────────────────────────────────────────────────────────────── + +const NAV_SECTIONS: NavSection[] = [ + { id: "section-1", label: "1. Объект" }, + { id: "section-2", label: "2. Земля и риски" }, + { + id: "section-3", + label: "3. Рынок", + sub: [ + { id: "section-3-1", label: "3.1 Настройки выборки" }, + { id: "section-3-2", label: "3.2 Планировки" }, + { id: "section-3-3", label: "3.3 Остатки и скорость" }, + ], + }, + { id: "section-4", label: "4. Инфраструктура" }, + { id: "section-5", label: "5. Свежесть" }, +]; + +// All section IDs in scroll order (for IntersectionObserver) +const ALL_SECTION_IDS: string[] = NAV_SECTIONS.flatMap((s) => + s.sub ? [s.id, ...s.sub.map((sub) => sub.id)] : [s.id], +); + +// ── Component ───────────────────────────────────────────────────────────────── + +export function AnalysisSidebar() { + const [activeId, setActiveId] = useState(ALL_SECTION_IDS[0]); + const observerRef = useRef(null); + + // Scrollspy via IntersectionObserver + useEffect(() => { + const candidates = ALL_SECTION_IDS.map((id) => + document.getElementById(id), + ).filter((el): el is HTMLElement => el !== null); + + if (candidates.length === 0) return; + + // Track which sections are visible; pick topmost visible one + const visible = new Set(); + + observerRef.current = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + visible.add(entry.target.id); + } else { + visible.delete(entry.target.id); + } + }); + + // Pick the topmost section that is currently visible + const next = ALL_SECTION_IDS.find((id) => visible.has(id)); + if (next) setActiveId(next); + }, + { + root: null, + // Trigger when section top enters top 60% of viewport + rootMargin: "-8px 0px -40% 0px", + threshold: 0, + }, + ); + + candidates.forEach((el) => observerRef.current!.observe(el)); + + return () => { + observerRef.current?.disconnect(); + }; + }, []); + + function handleAnchorClick( + e: React.MouseEvent, + targetId: string, + ) { + e.preventDefault(); + const el = document.getElementById(targetId); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "start" }); + } + setActiveId(targetId); + } + + return ( + + ); +} diff --git a/frontend/src/components/site-finder/analysis/UserAvatar.tsx b/frontend/src/components/site-finder/analysis/UserAvatar.tsx new file mode 100644 index 00000000..38760a3c --- /dev/null +++ b/frontend/src/components/site-finder/analysis/UserAvatar.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Building2 } from "lucide-react"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function getStoredOrgId(): string | null { + // Guard against SSR — localStorage not available on server + if (typeof window === "undefined") return null; + try { + return localStorage.getItem("gd_org_id"); + } catch { + return null; + } +} + +function orgInitials(orgId: string): string { + // Build a 2-letter monogram from org ID string + const parts = orgId + .toUpperCase() + .replace(/[^A-ZА-Я0-9]/gu, " ") + .split(" ") + .filter(Boolean); + if (parts.length === 0) return "??"; + if (parts.length === 1) return parts[0].slice(0, 2); + return parts[0][0] + parts[1][0]; +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export function UserAvatar() { + const [orgId, setOrgId] = useState(null); + + // Hydration-safe: read localStorage after mount + useEffect(() => { + setOrgId(getStoredOrgId()); + }, []); + + const displayLabel = orgId ?? "Demo Org"; + const initials = orgId ? orgInitials(orgId) : "DO"; + + return ( +
+ {/* Avatar circle */} +
+ {orgId ? ( + + {initials} + + ) : ( + + )} +
+ + {/* Org name — hidden on narrow viewports via maxWidth trick */} + + {displayLabel} + +
+ ); +}