feat(sf-fe-a4): AnalysisSidebar (scrollspy) + Breadcrumb + UserAvatar
Wire analysis/[cad]/page.tsx: replace SidebarPlaceholder with AnalysisSidebar (sticky, IntersectionObserver scrollspy on sections 1/2/3/3.1/3.2/3.3/4/5), header with AnalysisBreadcrumb (SiteFinder → cad → Анализ) and UserAvatar (gd_org_id from localStorage, SSR-safe). Add lucide-react to package.json (was used in A1 but missing from deps).
This commit is contained in:
parent
2548f71f40
commit
440b53cef0
5 changed files with 487 additions and 105 deletions
|
|
@ -17,6 +17,7 @@
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet-draw": "^1.0.4",
|
"leaflet-draw": "^1.0.4",
|
||||||
|
"lucide-react": "^0.511.0",
|
||||||
"next": "^15.0.0",
|
"next": "^15.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { use } from "react";
|
import { use } from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import { LayoutDashboard } from "lucide-react";
|
import { LayoutDashboard } from "lucide-react";
|
||||||
|
|
||||||
// ── Placeholder components ─────────────────────────────────────────────────
|
import { AnalysisSidebar } from "@/components/site-finder/analysis/AnalysisSidebar";
|
||||||
// Replaced in Wave 2–4 with real sections
|
import { AnalysisBreadcrumb } from "@/components/site-finder/analysis/AnalysisBreadcrumb";
|
||||||
|
import { UserAvatar } from "@/components/site-finder/analysis/UserAvatar";
|
||||||
|
|
||||||
function SidebarPlaceholder() {
|
// ── Section placeholder (Wave 3–4 refactor will replace these) ─────────────────
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SectionPlaceholderProps {
|
interface SectionPlaceholderProps {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -145,7 +86,7 @@ export default function AnalysisPage({ params }: PageProps) {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header / breadcrumb */}
|
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
||||||
<header
|
<header
|
||||||
style={{
|
style={{
|
||||||
background: "var(--bg-card)",
|
background: "var(--bg-card)",
|
||||||
|
|
@ -154,42 +95,25 @@ export default function AnalysisPage({ params }: PageProps) {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 12,
|
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}/> */}
|
<AnalysisBreadcrumb cadNum={cad} />
|
||||||
<Link
|
|
||||||
href="/site-finder"
|
{/* Push avatar to the right */}
|
||||||
style={{
|
<div style={{ marginLeft: "auto" }}>
|
||||||
fontSize: 13,
|
<UserAvatar />
|
||||||
color: "var(--fg-secondary)",
|
</div>
|
||||||
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/> справа */}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Body: sidebar + scrollable sections */}
|
{/* ── Body: sidebar + scrollable sections ───────────────────────────── */}
|
||||||
<div style={{ display: "flex", flex: 1 }}>
|
<div style={{ display: "flex", flex: 1 }}>
|
||||||
{/* TODO A4: replace with <AnalysisSidebar/> */}
|
<AnalysisSidebar />
|
||||||
<SidebarPlaceholder />
|
|
||||||
|
|
||||||
{/* Main scrollable content */}
|
{/* Main scrollable content */}
|
||||||
<article
|
<article
|
||||||
|
|
@ -200,38 +124,55 @@ export default function AnalysisPage({ params }: PageProps) {
|
||||||
maxWidth: 1000,
|
maxWidth: 1000,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* TODO A5: Section 1 — HeadlineBar + 4 KPI + mini-map + EgrnPropertyTable + PoiList2Gis */}
|
{/* Section 1 — TODO A5 */}
|
||||||
<SectionPlaceholder
|
<SectionPlaceholder
|
||||||
id="section-1"
|
id="section-1"
|
||||||
title="1. Инфо об участке"
|
title="1. Объект"
|
||||||
note="TODO A5: HeadlineBar вердикт + 4 KPI (Площадь / Район / Медиана / Score) + mini-map + EGRN table + POI list 2GIS"
|
note="TODO A5: HeadlineBar вердикт + 4 KPI (Площадь / Район / Медиана / Score) + mini-map + EGRN table + POI list 2GIS"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* TODO A6: Section 2 — NspdEngineeringNearbyBlock */}
|
{/* Section 2 — TODO A6 */}
|
||||||
<SectionPlaceholder
|
<SectionPlaceholder
|
||||||
id="section-2"
|
id="section-2"
|
||||||
title="2. Сети и точки подключения"
|
title="2. Земля и риски"
|
||||||
note="TODO A6: reuse NspdEngineeringNearbyBlock в новом layout headline+map"
|
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
|
<SectionPlaceholder
|
||||||
id="section-3"
|
id="section-3"
|
||||||
title="3. Конкуренты"
|
title="3. Рынок"
|
||||||
note="TODO A7: WeightProfilePanel (настройки выборки) · A8: BestLayoutsBlock / Pipeline24moBlock / VelocityBlock · A9: CompetitorTable с drawer drill-in"
|
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
|
<SectionPlaceholder
|
||||||
id="section-4"
|
id="section-4"
|
||||||
title="4. Оценка участка"
|
title="4. Инфраструктура"
|
||||||
note="TODO A10: ScoreBreakdownPanel + ScoreBreakdownStackedBar + GateVerdictBanner + GeologyBlock / HydrologyBlock / GeotechRiskBlock"
|
note="TODO A10: ScoreBreakdownPanel + ScoreBreakdownStackedBar + GateVerdictBanner + GeologyBlock / HydrologyBlock / GeotechRiskBlock"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* TODO A11: Section 5 — SeasonalWeatherBlock + air quality */}
|
{/* Section 5 — TODO A11 */}
|
||||||
<SectionPlaceholder
|
<SectionPlaceholder
|
||||||
id="section-5"
|
id="section-5"
|
||||||
title="5. Атмосфера / воздух"
|
title="5. Свежесть"
|
||||||
note="TODO A11: SeasonalWeatherBlock + air quality (reuse EnvironmentTab logic split на блоки)"
|
note="TODO A11: SeasonalWeatherBlock + air quality (reuse EnvironmentTab logic split на блоки)"
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
254
frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx
Normal file
254
frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
frontend/src/components/site-finder/analysis/UserAvatar.tsx
Normal file
104
frontend/src/components/site-finder/analysis/UserAvatar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue