gendesign/tradein-mvp/frontend/src/app/v2/page.tsx
bot-backend a3cab9023f
All checks were successful
CI Trade-In / changes (pull_request) Successful in 16s
CI / changes (pull_request) Successful in 16s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m13s
fix(tradein/v2/a11y): немодальный SectionOverlay + контраст токенов + headings — a11y round 3 (#2264)
- SectionOverlay: убран aria-modal (честный non-modal fullscreen-view); focus-trap
  теперь охватывает живой верхний nav + оверлей (query по артборду, фильтр [inert]),
  Tab из nav возвращается в оверлей, не в спрятанный main/footer.
- Контраст 6 muted/disabled токенов затемнён до >=4.6:1 vs worst-case #dce6f1
  (hue сохранён, lightness-only): muted2/3/4, hint, disabledValue/Label.
- accent -> accentDeep как мелкий текст: номер секции оверлея (15px), счётчик
  "Мои отчёты" (10px).
- Семантика заголовков: sr-only h1 «Мера — оценка квартиры» + h2 (оверлей,
  ResultPanel, ObjectSummary, placeholder-панели).
- HeroBar «КОЭФ. ЛОКАЦИИ»: role=button + tabIndex + Enter/Space + aria-label.
- Фокус после успешной оценки переносится на результат-регион (role=region,
  tabIndex=-1, aria-labelledby); live-region «Оценка готова» сохранён.
- Инпут focus-ring усилен (2px solid accentDeep).
- combobox адреса: aria-live анонс количества подсказок; aria-required на
  адрес + площадь; декоративные inline-SVG помечены aria-hidden.
2026-07-03 10:37:42 +03:00

843 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
// /trade-in/v2 — "МЕРА Оценка" HUD dashboard, wired to the real trade-in API.
// This page OWNS the data: it runs the estimate mutation + restore-by-id query,
// spawns the dashboard sub-hooks (street deals, house analytics) and feeds the
// pixel-perfect HUD components via the pure mappers (mapReport / mapObject /
// mapResultPanel / mapSummary). The artboard frame / brackets / 474-1fr-250 grid
// are unchanged — only the result/summary areas swap between honest empty / grey
// skeleton / inline-error / real-data states. Sub-hooks resolve independently:
// a pending or errored street-deals / analytics call degrades its section to a
// fallback inside the mapper instead of blanking the page.
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { tokens } from "@/components/trade-in/v2/tokens";
import TopNav from "@/components/trade-in/v2/TopNav";
import HeroBar from "@/components/trade-in/v2/HeroBar";
import ParamsPanel from "@/components/trade-in/v2/ParamsPanel";
import ResultPanel from "@/components/trade-in/v2/ResultPanel";
import { ObjectSummary } from "@/components/trade-in/v2/ObjectSummary";
import { Footer } from "@/components/trade-in/v2/Footer";
import SectionOverlay from "@/components/trade-in/v2/SectionOverlay";
import { LocationDrawer } from "@/components/trade-in/v2/LocationDrawer";
import type {
Analytics,
ObjectInfo,
Report,
} from "@/components/trade-in/v2/types";
import type {
CacheData,
HistoryData,
SourcesData,
} from "@/components/trade-in/v2/mappers";
import {
mapAnalytics,
mapCache,
mapHistory,
mapMarkers,
mapObject,
mapReport,
mapResultPanel,
mapSources,
mapSummary,
} from "@/components/trade-in/v2/mappers";
import type {
AggregatedEstimate,
TradeInEstimateInput,
} from "@/types/trade-in";
import { HTTPError } from "@/lib/api";
import {
useEstimate,
useEstimateHistory,
useEstimateHouseAnalytics,
useEstimateMutation,
useEstimatePlacementHistory,
useEstimateSellTimeSensitivity,
useSalesVsListings,
useStreetDeals,
} from "@/lib/trade-in-api";
import { useQuota } from "@/lib/useQuota";
import { useMe } from "@/lib/useMe";
import { logout } from "@/lib/logout";
// OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative,
// non-interactive overlay drawn over the artboard gradient. The frame has
// chamfered octagonal corners (clip-path polygon) + 4px radius; the brackets
// sit just outside it at the artboard corners.
const FRAME_INSET = 14;
const BRACKET_INSET = 6;
const BRACKET_ARM = 22;
const BRACKET_W = 1.5;
const FRAME_CLIP =
"polygon(18px 0,calc(100% - 18px) 0,100% 18px,100% calc(100% - 18px),calc(100% - 18px) 100%,18px 100%,0 calc(100% - 18px),0 18px)";
const brackets: { key: string; style: CSSProperties }[] = [
{
key: "tl",
style: {
top: BRACKET_INSET,
left: BRACKET_INSET,
borderTop: `${BRACKET_W}px solid ${tokens.bracket2}`,
borderLeft: `${BRACKET_W}px solid ${tokens.bracket2}`,
},
},
{
key: "tr",
style: {
top: BRACKET_INSET,
right: BRACKET_INSET,
borderTop: `${BRACKET_W}px solid ${tokens.bracket2}`,
borderRight: `${BRACKET_W}px solid ${tokens.bracket2}`,
},
},
{
key: "bl",
style: {
bottom: BRACKET_INSET,
left: BRACKET_INSET,
borderBottom: `${BRACKET_W}px solid ${tokens.bracket2}`,
borderLeft: `${BRACKET_W}px solid ${tokens.bracket2}`,
},
},
{
key: "br",
style: {
bottom: BRACKET_INSET,
right: BRACKET_INSET,
borderBottom: `${BRACKET_W}px solid ${tokens.bracket2}`,
borderRight: `${BRACKET_W}px solid ${tokens.bracket2}`,
},
},
];
// Honest neutral fallbacks for the meta blocks (HeroBar / Footer) before there
// is an estimate. Dashes — never the design fixtures (which would read as a fake
// real report).
const EMPTY_REPORT: Report = { id: "—", date: "—", validUntil: "—" };
const EMPTY_OBJECT: ObjectInfo = {
address: "—",
city: "",
area: "—",
rooms: "—",
floor: "—",
totalFloors: "—",
year: "—",
houseType: "—",
repair: "—",
balcony: false,
locationCoef: "—",
streetView: "",
compass: "",
};
// Skeleton pulse (opacity only — NO shimmer sweep, per .claude/rules/ui-*) +
// retry-button hover.
const HELPER_STYLES = `
@keyframes v2-skel-pulse{0%{opacity:.45}50%{opacity:.78}100%{opacity:.45}}
.v2-skel{animation:v2-skel-pulse 1.5s ease-in-out infinite}
.v2-retry-btn{transition:all .15s;cursor:pointer}
.v2-retry-btn:hover{border-color:${tokens.accent};color:${tokens.accent};background:rgba(46,139,255,.07)}
`;
const retryBtnStyle: CSSProperties = {
padding: "8px 16px",
background: "transparent",
border: `1px solid ${tokens.line}`,
borderRadius: 6,
fontFamily: tokens.font.sans,
fontSize: 11,
fontWeight: 600,
letterSpacing: 1.5,
color: tokens.muted,
};
// ── Result/summary area state panels (kept inside the page — these are layout
// states, not reusable design components, so they do not belong in v2/). ───
function SkeletonBlock({ height, flex }: { height?: number; flex?: number }) {
return (
<div
className="v2-skel"
style={{
height,
flex,
minHeight: 0,
background: tokens.surface.w50,
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
}}
/>
);
}
function ResultSkeleton() {
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 14,
minWidth: 0,
height: "100%",
}}
>
<SkeletonBlock height={18} />
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gap: 14,
flex: "0 0 auto",
}}
>
<SkeletonBlock height={150} />
<SkeletonBlock height={150} />
<SkeletonBlock height={150} />
</div>
<SkeletonBlock height={150} />
<SkeletonBlock flex={1} />
</div>
);
}
function SummarySkeleton() {
return (
<div
className="v2-skel"
style={{
height: "100%",
background: tokens.surface.w50,
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
}}
/>
);
}
function PlaceholderPanel({
title,
body,
children,
}: {
title: string;
body: string;
children?: ReactNode;
}) {
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 12,
background: tokens.surface.w50,
backdropFilter: "blur(6px)",
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
padding: "40px 28px",
fontFamily: tokens.font.sans,
}}
>
{/* Panel heading — real <h2> (preflight not loaded → reset UA margin). */}
<h2
style={{
margin: 0,
fontSize: 13,
fontWeight: 600,
letterSpacing: 2,
color: tokens.ink,
}}
>
{title}
</h2>
<div
style={{
fontSize: 12,
color: tokens.muted,
maxWidth: 360,
lineHeight: 1.5,
}}
>
{body}
</div>
{children}
</div>
);
}
function EmptyResultPanel() {
return (
<PlaceholderPanel
title="РЕЗУЛЬТАТ ОЦЕНКИ"
body="Заполните параметры квартиры слева и нажмите «ОЦЕНИТЬ КВАРТИРУ» — здесь появится рыночная оценка, диапазоны цен и источники данных."
/>
);
}
function InsufficientPanel() {
return (
<PlaceholderPanel
title="НЕДОСТАТОЧНО ДАННЫХ"
body="По этому адресу не нашлось достаточного числа аналогов и сделок для надёжной оценки. Уточните адрес или параметры квартиры."
/>
);
}
function RestoreErrorPanel({
notFound,
onRetry,
onNew,
}: {
notFound: boolean;
onRetry: () => void;
onNew: () => void;
}) {
return (
<PlaceholderPanel
title={notFound ? "ОТЧЁТ НЕ НАЙДЕН" : "ОШИБКА ЗАГРУЗКИ"}
body={
notFound
? "Ссылка устарела, отчёт удалён или у вас нет к нему доступа."
: "Не удалось загрузить отчёт. Попробуйте ещё раз."
}
>
<div style={{ display: "flex", gap: 10, marginTop: 4 }}>
{!notFound && (
<button
type="button"
className="v2-retry-btn"
onClick={onRetry}
style={retryBtnStyle}
>
ПОВТОРИТЬ
</button>
)}
<button
type="button"
className="v2-retry-btn"
onClick={onNew}
style={retryBtnStyle}
>
НОВАЯ ОЦЕНКА
</button>
</div>
</PlaceholderPanel>
);
}
function SummaryPlaceholder() {
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 8,
background: tokens.surface.w50,
backdropFilter: "blur(6px)",
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
padding: "28px 18px",
fontFamily: tokens.font.sans,
}}
>
{/* Panel heading — real <h2> (preflight not loaded → reset UA margin). */}
<h2
style={{
margin: 0,
fontSize: 12,
fontWeight: 600,
letterSpacing: 1.5,
color: tokens.ink,
}}
>
СВОДКА ОБЪЕКТА
</h2>
<div style={{ fontSize: 11, color: tokens.muted, lineHeight: 1.5 }}>
Появится после оценки квартиры.
</div>
</div>
);
}
// Restore-by-id: read ?id= from the URL with an SSR guard. Mirrors v1 (app/
// page.tsx) deliberately — next/navigation useSearchParams would force a
// Suspense boundary and break `next build`.
function readUrlId(): string | null {
if (typeof window === "undefined") return null;
return new URLSearchParams(window.location.search).get("id");
}
export default function TradeInV2Page() {
const router = useRouter();
const queryClient = useQueryClient();
const [nav, setNav] = useState(0);
const [drawerOpen, setDrawerOpen] = useState(false);
// #2264 C7: after a fresh estimate lands we move focus onto the result region
// (a labelled, tabIndex={-1} container in ResultPanel) so keyboard/SR users
// don't get dumped back on <body>. `focusedEstimateRef` guards against
// re-focusing on unrelated re-renders / sub-hook updates.
const resultRegionRef = useRef<HTMLDivElement>(null);
const focusedEstimateRef = useRef<string | null>(null);
// Locally-held just-computed estimate (so we render instantly after submit
// without a round-trip through the restore query).
const [freshResult, setFreshResult] = useState<AggregatedEstimate | null>(
null,
);
const urlId = readUrlId();
// Post-mount flag: readUrlId() is null on SSR but a UUID on the client's first
// render, so any structure that depends on the id (e.g. the PDF <a> vs disabled
// <button>) would diverge between SSR and hydration on a fresh /v2?id= load.
// Gate id-dependent structure behind `mounted` to keep first render identical.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// Scale-fit: shrink the fixed 1536×1024 artboard to fit smaller viewports
// instead of clipping. Never upscale (capped at 1); recomputed on resize.
const [artboardScale, setArtboardScale] = useState(1);
useEffect(() => {
const compute = () =>
setArtboardScale(
Math.max(
0.88,
Math.min(1, window.innerWidth / 1536, window.innerHeight / 1024),
),
);
compute();
window.addEventListener("resize", compute);
return () => window.removeEventListener("resize", compute);
}, []);
const mutation = useEstimateMutation();
// Skip the restore fetch once we already hold a fresh result.
const restored = useEstimate(freshResult === null ? urlId : null);
const estimate: AggregatedEstimate | null =
freshResult ?? restored.data ?? null;
const currentEstimateId = freshResult?.estimate_id ?? urlId;
// Per-user side data, independent of the current estimate. Both fail-soft
// (retry:false): history feeds the «Предыдущие оценки» overlay (04), quota
// feeds the TopNav «Мои отчёты» badge. Refreshed after a successful estimate
// via invalidateQueries in handleSubmit.
const history = useEstimateHistory();
const quota = useQuota();
// Real logged-in identity for the TopNav user menu (replaces the design
// fixture). /me returns {username, role, brand} — no display name / email, so
// name falls back to username, org to brand ?? role, email to "" (renders
// blank, never invented). undefined while loading → TopNav «Гость» fallback.
const me = useMe();
// Dashboard sub-hooks — each resolves independently; failure degrades its
// section via the mappers (null input) rather than blanking the page.
const streetDeals = useStreetDeals(
estimate?.target_address ?? null,
estimate?.area_m2 ?? null,
estimate?.rooms ?? null,
);
const analytics = useEstimateHouseAnalytics(currentEstimateId);
// Overlay-only sub-hooks (04 ПРОДАЖИ В ДОМЕ / 05 РЫНОК / 06 АНАЛИТИКА). Same
// contract: each resolves independently; a pending/errored one degrades its
// overlay section to an honest empty via the mapper (null input).
const placementHistory = useEstimatePlacementHistory(currentEstimateId);
const salesVsListings = useSalesVsListings(
estimate?.target_address ?? null,
estimate?.area_m2 ?? null,
estimate?.rooms ?? null,
);
const sellTime = useEstimateSellTimeSensitivity(currentEstimateId);
const streetDealsData = streetDeals.data ?? null;
const analyticsData = analytics.data ?? null;
const placementHistoryData = placementHistory.data ?? null;
const salesVsListingsData = salesVsListings.data ?? null;
const sellTimeData = sellTime.data ?? null;
// ── Derived state flags ───────────────────────────────────────────────────
const restoreActive = urlId !== null && freshResult === null;
const restoreLoading = restoreActive && restored.isPending;
const restoreError = restoreActive && restored.isError;
const restoreNotFound =
restoreError &&
restored.error instanceof HTTPError &&
restored.error.status === 404;
const loading = mutation.isPending || restoreLoading;
const insufficient =
estimate != null &&
(estimate.insufficient_data || estimate.n_analogs === 0);
const apiError = mutation.error?.message ?? null;
// M4: estimate-dependent meta/controls (the «ДЕЙСТВИТЕЛЕН ДО» validity line,
// «КАК РАССЧИТАНО», PDF) only render once there is a real, sufficient estimate.
// Folds in `mounted` so SSR and the first client render agree (false → hidden)
// — no hydration drift, same reason the PDF control is gated behind `mounted`.
const hasEstimate = mounted && estimate != null && !insufficient;
// ── Mapped presentation data (memoised so nav/drawer toggles don't recompute
// geometry). ──────────────────────────────────────────────────────────
const report = useMemo(
() => (estimate ? mapReport(estimate) : EMPTY_REPORT),
[estimate],
);
const objectInfo = useMemo(
() => (estimate ? mapObject(estimate) : EMPTY_OBJECT),
[estimate],
);
const resultPanelData = useMemo(
() => (estimate ? mapResultPanel(estimate, streetDealsData) : null),
[estimate, streetDealsData],
);
const summaryData = useMemo(
() =>
estimate ? mapSummary(estimate, analyticsData, streetDealsData) : null,
[estimate, analyticsData, streetDealsData],
);
// Move focus to the result region once a user-submitted estimate is rendered.
// Gated on `freshResult` (a submit, not a restore-by-id cold load, which must
// not steal focus) and on the result actually being on screen (!loading +
// resultPanelData). Fires once per distinct estimate id.
useEffect(() => {
if (
freshResult &&
!loading &&
hasEstimate &&
resultPanelData &&
focusedEstimateRef.current !== freshResult.estimate_id
) {
focusedEstimateRef.current = freshResult.estimate_id;
resultRegionRef.current?.focus();
}
}, [freshResult, loading, hasEstimate, resultPanelData]);
// ── Overlay (section) mapped data. Computed unconditionally: the mappers
// accept nulls and degrade per-section, so an overlay opened before/without
// an estimate shows an honest empty shell — never the design fixtures as if
// they were a real report. ─────────────────────────────────────────────
const historyData = useMemo<HistoryData>(
() =>
mapHistory(placementHistoryData, streetDealsData, salesVsListingsData),
[placementHistoryData, streetDealsData, salesVsListingsData],
);
const analyticsViewData = useMemo<Analytics>(
() => mapAnalytics(analyticsData, sellTimeData, estimate),
[analyticsData, sellTimeData, estimate],
);
const sourcesData = useMemo<SourcesData>(
() => mapSources(estimate, streetDealsData),
[estimate, streetDealsData],
);
// «Предыдущие оценки» (04) — KPIs + last-estimates table derived purely from
// the per-user /history list (never /cache-stats, which is global/admin).
const cacheData = useMemo<CacheData>(
() => mapCache(history.data ?? null),
[history.data],
);
// TopNav «Мои отчёты» badge: per-user estimate count. quota.used is the
// authoritative monthly counter; fall back to the loaded history length, then
// to the fixture default (undefined) for unwired/pre-load states.
const reportsCount = quota.data?.used ?? history.data?.length;
// Real user for the TopNav menu. undefined until /me resolves → TopNav shows
// its neutral «Гость» fallback (never the old "Андрей Петров / Брусника").
const topNavUser = useMemo(() => {
const scope = me.data;
if (!scope) return undefined;
return {
name: scope.username,
org: scope.brand ?? scope.role,
email: "",
initials: scope.username.slice(0, 2).toUpperCase(),
};
}, [me.data]);
// Analog price pins for the 01 map, projected from the real estimate. No
// estimate → mapMarkers(null) → [] → ParamsPanel renders only the subject pin
// (Finding #2: never the static fixture price markers).
const markers = useMemo(() => mapMarkers(estimate), [estimate]);
// Prefill for restore-by-id / re-estimate. Keyed remount applies it when the
// estimate arrives async (useState reads initialValues only on mount).
const initialValues = useMemo<Partial<TradeInEstimateInput> | undefined>(
() =>
estimate
? {
address: estimate.target_address ?? undefined,
area_m2: estimate.area_m2 ?? undefined,
rooms: estimate.rooms ?? undefined,
floor: estimate.floor,
total_floors: estimate.total_floors,
year_built: estimate.year_built ?? undefined,
house_type: estimate.house_type ?? undefined,
repair_state: estimate.repair_state ?? undefined,
has_balcony: estimate.has_balcony ?? undefined,
}
: undefined,
[estimate],
);
function handleSubmit(input: TradeInEstimateInput) {
mutation.mutate(input, {
onSuccess: (est) => {
setFreshResult(est);
// A new estimate consumed quota and added a history row — refresh both
// so the TopNav badge + «Предыдущие оценки» overlay reflect it.
void queryClient.invalidateQueries({
queryKey: ["trade-in", "history"],
});
void queryClient.invalidateQueries({
queryKey: ["trade-in", "quota"],
});
// basePath auto-prefixes → /trade-in/v2?id=...
router.replace(`/v2?id=${est.estimate_id}`, { scroll: false });
},
});
}
function handleRetry() {
void restored.refetch();
}
function handleNew() {
setFreshResult(null);
router.replace("/v2", { scroll: false });
}
let middleContent: ReactNode;
if (loading) {
middleContent = <ResultSkeleton />;
} else if (restoreError) {
middleContent = (
<RestoreErrorPanel
notFound={restoreNotFound}
onRetry={handleRetry}
onNew={handleNew}
/>
);
} else if (estimate && !insufficient && resultPanelData) {
middleContent = (
<ResultPanel
data={resultPanelData}
onNavigate={setNav}
regionRef={resultRegionRef}
/>
);
} else if (estimate && insufficient) {
middleContent = <InsufficientPanel />;
} else {
middleContent = <EmptyResultPanel />;
}
let rightContent: ReactNode;
if (loading) {
rightContent = <SummarySkeleton />;
} else if (estimate && !insufficient && summaryData) {
rightContent = (
<ObjectSummary
data={{ object: objectInfo, summary: summaryData }}
onNavigate={setNav}
/>
);
} else {
rightContent = <SummaryPlaceholder />;
}
// Polite SR announcement reflecting the estimate lifecycle. Same precedence as
// middleContent: loading → error → insufficient → ready → silent.
let statusMessage = "";
if (loading) {
statusMessage = "Идёт расчёт оценки…";
} else if (restoreError) {
statusMessage = "Ошибка загрузки отчёта";
} else if (estimate && insufficient) {
statusMessage = "Недостаточно данных для оценки";
} else if (estimate && !insufficient) {
statusMessage = "Оценка готова";
}
return (
<div style={{ width: 1536 * artboardScale, height: 1024 * artboardScale }}>
<div
style={{
position: "relative",
width: 1536,
height: 1024,
overflow: "hidden",
background: tokens.gradientBg,
fontFamily: tokens.font.sans,
color: tokens.ink,
transform: `scale(${artboardScale})`,
transformOrigin: "top left",
}}
>
<style>{HELPER_STYLES}</style>
{/* #2264 C5: the page's single visible "title" is the SVG logo in TopNav,
so give the document a real, visually-hidden <h1> (page-has-heading-one
+ a top of the heading order for the overlay/panel <h2>s). */}
<h1
style={{
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0 0 0 0)",
whiteSpace: "nowrap",
border: 0,
}}
>
Мера оценка квартиры
</h1>
{/* Polite, visually-hidden status announcer for SR users. */}
<div
aria-live="polite"
role="status"
style={{
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0 0 0 0)",
whiteSpace: "nowrap",
border: 0,
}}
>
{statusMessage}
</div>
{/* OUTER HUD FRAME */}
<div
style={{
position: "absolute",
inset: FRAME_INSET,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
clipPath: FRAME_CLIP,
pointerEvents: "none",
}}
/>
{/* 4 corner brackets */}
{brackets.map((b) => (
<div
key={b.key}
style={{
position: "absolute",
width: BRACKET_ARM,
height: BRACKET_ARM,
pointerEvents: "none",
...b.style,
}}
/>
))}
{/* CONTENT WRAP */}
<div
style={{
position: "absolute",
inset: 14,
padding: "16px 28px",
display: "flex",
flexDirection: "column",
}}
>
{/* TopNav stays interactive while a section overlay is open so the
user can switch sections directly from the overlay. Only the
LocationDrawer (a full modal over the whole HUD) inerts it. The
dashboard body (<main>/<footer>) is still inerted behind ANY
overlay/drawer — preserving the focus + click guard from a11y
audit #2081 for everything except the top tabs. */}
<nav
aria-label="Разделы"
inert={drawerOpen ? true : undefined}
style={{ display: "contents" }}
>
<TopNav
active={nav}
onNavigate={setNav}
reports={reportsCount ?? 0}
user={topNavUser}
onLogout={logout}
/>
</nav>
<main
inert={nav !== 0 || drawerOpen ? true : undefined}
style={{ display: "contents" }}
>
<HeroBar
data={{ report, object: objectInfo }}
estimateId={mounted ? currentEstimateId : null}
onOpenInfo={() => setDrawerOpen(true)}
hasEstimate={hasEstimate}
/>
<div
style={{
display: "grid",
gridTemplateColumns: "474px 1fr 250px",
gap: 18,
flex: 1,
minHeight: 0,
}}
>
<ParamsPanel
key={estimate?.estimate_id ?? "new"}
onSubmit={handleSubmit}
isPending={mutation.isPending}
error={apiError}
initialValues={initialValues}
markers={markers}
/>
{middleContent}
{rightContent}
</div>
</main>
<footer
inert={nav !== 0 || drawerOpen ? true : undefined}
style={{ display: "contents" }}
>
<Footer data={report} hasEstimate={hasEstimate} />
</footer>
</div>
{nav !== 0 && (
<SectionOverlay
active={nav}
onClose={() => setNav(0)}
onNavigate={setNav}
history={historyData}
analytics={analyticsViewData}
sources={sourcesData}
cache={cacheData}
/>
)}
<LocationDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
/>
</div>
</div>
);
}