Merge pull request 'feat(sf-fe-a4): analysis sidebar (scrollspy) + breadcrumb + user avatar' (#344) from feat/sf-fe-a4-sidebar into main
Some checks failed
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / deploy (push) Has been skipped
Deploy / build-frontend (push) Failing after 26s

This commit is contained in:
lekss361 2026-05-17 22:20:03 +00:00
commit 956e461b9f
5 changed files with 487 additions and 105 deletions

View file

@ -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",

View file

@ -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 24 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 (
<aside
style={{
width: 240,
flexShrink: 0,
background: "var(--bg-card)",
borderRight: "1px solid var(--border-card)",
padding: "16px 12px",
display: "flex",
flexDirection: "column",
gap: 8,
minHeight: "calc(100vh - 56px)",
}}
>
<p
style={{
fontSize: 12,
fontWeight: 500,
textTransform: "uppercase" as const,
letterSpacing: "0.04em",
color: "var(--fg-tertiary)",
margin: 0,
}}
>
TODO A4: AnalysisSidebar
</p>
{/* Numbered nav placeholders */}
{[
"1. Инфо об участке",
"2. Сети и точки подключения",
"3. Конкуренты",
"4. Оценка участка",
"5. Атмосфера / воздух",
].map((label) => (
<div
key={label}
style={{
padding: "8px 12px",
borderRadius: 8,
background: "var(--bg-card-alt)",
color: "var(--fg-secondary)",
fontSize: 13,
}}
>
{label}
</div>
))}
<div
style={{
marginTop: "auto",
fontSize: 12,
color: "var(--fg-tertiary)",
}}
>
TODO A4: «Источники» внизу sidebar
</div>
</aside>
);
}
// ── Section placeholder (Wave 34 refactor will replace these) ─────────────────
interface SectionPlaceholderProps {
id: string;
@ -145,7 +86,7 @@ export default function AnalysisPage({ params }: PageProps) {
flexDirection: "column",
}}
>
{/* Header / breadcrumb */}
{/* ── Header ─────────────────────────────────────────────────────────── */}
<header
style={{
background: "var(--bg-card)",
@ -154,42 +95,25 @@ export default function AnalysisPage({ params }: PageProps) {
display: "flex",
alignItems: "center",
gap: 12,
flexWrap: "wrap" as const,
flexWrap: "wrap",
position: "sticky",
top: 0,
zIndex: 10,
minHeight: 56,
boxSizing: "border-box",
}}
>
{/* TODO A4: replace with <AnalysisBreadcrumb cad={cad}/> */}
<Link
href="/site-finder"
style={{
fontSize: 13,
color: "var(--fg-secondary)",
textDecoration: "none",
}}
>
К карте
</Link>
<span style={{ color: "var(--border-soft)" }}>·</span>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--fg-primary)",
fontVariantNumeric: "tabular-nums",
}}
>
{cad}
</span>
<span style={{ color: "var(--border-soft)" }}>·</span>
<span style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
TODO A4: район · площадь · анализ свежий N мин
</span>
{/* TODO A4: <UserAvatar/> справа */}
<AnalysisBreadcrumb cadNum={cad} />
{/* Push avatar to the right */}
<div style={{ marginLeft: "auto" }}>
<UserAvatar />
</div>
</header>
{/* Body: sidebar + scrollable sections */}
{/* ── Body: sidebar + scrollable sections ───────────────────────────── */}
<div style={{ display: "flex", flex: 1 }}>
{/* TODO A4: replace with <AnalysisSidebar/> */}
<SidebarPlaceholder />
<AnalysisSidebar />
{/* Main scrollable content */}
<article
@ -200,38 +124,55 @@ export default function AnalysisPage({ params }: PageProps) {
maxWidth: 1000,
}}
>
{/* TODO A5: Section 1 — HeadlineBar + 4 KPI + mini-map + EgrnPropertyTable + PoiList2Gis */}
{/* Section 1 — TODO A5 */}
<SectionPlaceholder
id="section-1"
title="1. Инфо об участке"
title="1. Объект"
note="TODO A5: HeadlineBar вердикт + 4 KPI (Площадь / Район / Медиана / Score) + mini-map + EGRN table + POI list 2GIS"
/>
{/* TODO A6: Section 2 — NspdEngineeringNearbyBlock */}
{/* Section 2 — TODO A6 */}
<SectionPlaceholder
id="section-2"
title="2. Сети и точки подключения"
title="2. Земля и риски"
note="TODO A6: reuse NspdEngineeringNearbyBlock в новом layout headline+map"
/>
{/* TODO A7/A8/A9: Section 3 — sub-sections 3.1 / 3.2 / 3.3 + CompetitorTable */}
{/* Section 3 — TODO A7/A8/A9 */}
<SectionPlaceholder
id="section-3"
title="3. Конкуренты"
title="3. Рынок"
note="TODO A7: WeightProfilePanel (настройки выборки) · A8: BestLayoutsBlock / Pipeline24moBlock / VelocityBlock · A9: CompetitorTable с drawer drill-in"
/>
{/* TODO A10: Section 4 — ScoreBreakdownPanel + GateVerdictBanner + geology/hydro/risk */}
{/* Sub-section anchor targets (invisible) for scrollspy / direct links */}
<div
id="section-3-1"
style={{ height: 0, overflow: "hidden" }}
aria-hidden="true"
/>
<div
id="section-3-2"
style={{ height: 0, overflow: "hidden" }}
aria-hidden="true"
/>
<div
id="section-3-3"
style={{ height: 0, overflow: "hidden" }}
aria-hidden="true"
/>
{/* Section 4 — TODO A10 */}
<SectionPlaceholder
id="section-4"
title="4. Оценка участка"
title="4. Инфраструктура"
note="TODO A10: ScoreBreakdownPanel + ScoreBreakdownStackedBar + GateVerdictBanner + GeologyBlock / HydrologyBlock / GeotechRiskBlock"
/>
{/* TODO A11: Section 5 — SeasonalWeatherBlock + air quality */}
{/* Section 5 — TODO A11 */}
<SectionPlaceholder
id="section-5"
title="5. Атмосфера / воздух"
title="5. Свежесть"
note="TODO A11: SeasonalWeatherBlock + air quality (reuse EnvironmentTab logic split на блоки)"
/>
</article>

View file

@ -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 (
<nav
aria-label="Breadcrumb"
style={{
display: "flex",
alignItems: "center",
gap: 4,
flexWrap: "wrap",
minWidth: 0,
flex: 1,
}}
>
{/* SiteFinder root */}
<Link
href="/site-finder"
style={{
fontSize: 13,
color: "var(--fg-secondary)",
textDecoration: "none",
whiteSpace: "nowrap",
}}
>
SiteFinder
</Link>
<ChevronRight
size={14}
strokeWidth={1.5}
style={{ color: "var(--fg-tertiary)", flexShrink: 0 }}
aria-hidden
/>
{/* Cad number */}
<Link
href={`/site-finder?selected=${encodeURIComponent(cadNum)}`}
style={{
fontSize: 13,
color: "var(--fg-secondary)",
textDecoration: "none",
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: 260,
}}
title={cadNum}
>
{cadNum}
</Link>
<ChevronRight
size={14}
strokeWidth={1.5}
style={{ color: "var(--fg-tertiary)", flexShrink: 0 }}
aria-hidden
/>
{/* Current page */}
<span
aria-current="page"
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--fg-primary)",
whiteSpace: "nowrap",
}}
>
Анализ
</span>
</nav>
);
}

View file

@ -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<string>(ALL_SECTION_IDS[0]);
const observerRef = useRef<IntersectionObserver | null>(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<string>();
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<HTMLAnchorElement>,
targetId: string,
) {
e.preventDefault();
const el = document.getElementById(targetId);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
setActiveId(targetId);
}
return (
<aside
style={{
width: 240,
flexShrink: 0,
background: "var(--bg-card)",
borderRight: "1px solid var(--border-card)",
padding: "16px 12px",
display: "flex",
flexDirection: "column",
gap: 4,
position: "sticky",
top: 56,
height: "calc(100vh - 56px)",
overflowY: "auto",
}}
>
{/* Section label */}
<p
style={{
fontSize: 12,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-tertiary)",
margin: "0 0 8px",
padding: "0 4px",
}}
>
Навигация
</p>
{/* Nav items */}
{NAV_SECTIONS.map((section) => {
const isParentActive =
activeId === section.id ||
section.sub?.some((s) => s.id === activeId);
return (
<div key={section.id}>
<a
href={`#${section.id}`}
onClick={(e) => handleAnchorClick(e, section.id)}
style={{
display: "block",
padding: "7px 10px",
borderRadius: 8,
fontSize: 13,
fontWeight: isParentActive ? 600 : 400,
color: isParentActive ? "var(--accent)" : "var(--fg-secondary)",
background: isParentActive
? "var(--accent-soft)"
: "transparent",
textDecoration: "none",
transition: "background 100ms, color 100ms",
lineHeight: 1.4,
}}
>
{section.label}
</a>
{/* Sub-sections */}
{section.sub && (
<div
style={{
paddingLeft: 12,
marginTop: 2,
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
{section.sub.map((sub) => {
const isSubActive = activeId === sub.id;
return (
<a
key={sub.id}
href={`#${sub.id}`}
onClick={(e) => handleAnchorClick(e, sub.id)}
style={{
display: "block",
padding: "5px 10px",
borderRadius: 6,
fontSize: 12,
fontWeight: isSubActive ? 600 : 400,
color: isSubActive
? "var(--accent)"
: "var(--fg-tertiary)",
background: isSubActive
? "var(--accent-soft)"
: "transparent",
textDecoration: "none",
transition: "background 100ms, color 100ms",
lineHeight: 1.4,
}}
>
{sub.label}
</a>
);
})}
</div>
)}
</div>
);
})}
{/* Sources footer */}
<div
style={{
marginTop: "auto",
paddingTop: 16,
borderTop: "1px solid var(--border-soft)",
}}
>
<p
style={{
fontSize: 11,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-tertiary)",
margin: "0 0 6px",
padding: "0 4px",
}}
>
Источники
</p>
{[
{ label: "Росреестр / ЕГРН", href: "https://rosreestr.gov.ru" },
{ label: "НСПД", href: "https://nspd.gov.ru" },
{ label: "2ГИС / OSM", href: "https://2gis.ru" },
].map(({ label, href }) => (
<a
key={label}
href={href}
target="_blank"
rel="noopener noreferrer"
style={{
display: "flex",
alignItems: "center",
gap: 4,
padding: "4px 4px",
fontSize: 11,
color: "var(--fg-tertiary)",
textDecoration: "none",
}}
>
<ExternalLink size={10} strokeWidth={1.5} />
{label}
</a>
))}
</div>
</aside>
);
}

View file

@ -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<string | null>(null);
// Hydration-safe: read localStorage after mount
useEffect(() => {
setOrgId(getStoredOrgId());
}, []);
const displayLabel = orgId ?? "Demo Org";
const initials = orgId ? orgInitials(orgId) : "DO";
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flexShrink: 0,
}}
title={displayLabel}
>
{/* Avatar circle */}
<div
aria-hidden
style={{
width: 32,
height: 32,
borderRadius: "50%",
background: "var(--accent-soft)",
border: "1px solid var(--border-card)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
{orgId ? (
<span
style={{
fontSize: 11,
fontWeight: 600,
color: "var(--accent)",
letterSpacing: "0.02em",
}}
>
{initials}
</span>
) : (
<Building2
size={14}
strokeWidth={1.5}
style={{ color: "var(--accent)" }}
/>
)}
</div>
{/* Org name — hidden on narrow viewports via maxWidth trick */}
<span
style={{
fontSize: 12,
fontWeight: 500,
color: "var(--fg-secondary)",
maxWidth: 140,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{displayLabel}
</span>
</div>
);
}