Browser audit of the live /trade-in/v2 found honesty + UX defects; fixes: - 🔴 LocationDrawer: removed FABRICATED coef 0.87 / fake 'base×coef=result' / invented POI factors / false 'OpenStreetMap' source. Now honest static 'ПОЯСНЕНИЕ К РАСЧЁТУ' (real methodology) + 'коэф. локации в разработке' note. + Escape-to-close. - 🔴 Map (ParamsPanel): removed static fixture price markers; renders REAL analog markers via new mapMarkers(estimate) (projects lat/lon around target, cap 6). - ParamsPanel: empty площадь/address now shows a validation message instead of silently blocking submit; numeric placeholders made clearly placeholders ('напр. 54'). - HeroBar: dead 'КАК РАССЧИТАНО' button now opens the info drawer; building.png next/image 400 fixed (unoptimized + onError fallback — basePath wasn't applied to the optimizer URL). - TopNav: real user from useMe() (name/initials/org/email) instead of fixture 'Андрей Петров'; dead Профиль/Настройки/Помощь dimmed/disabled; Выйти → logout(). - mappers: de-glue analog addresses ('69/2Геологическая'→'69/2 Геологическая', preserves house letters like 14А); 'Продажи в доме'→'Продажи рядом' (radius data); торг sign fixed (backend (start−last)/start positive=reduction → renders '−3.3%'). - HistoryView: '0 ЛОТА' → correct plural via pluralRu. next build green; code-reviewer ✅ APPROVE.
706 lines
22 KiB
TypeScript
706 lines
22 KiB
TypeScript
"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, 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,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
letterSpacing: 2,
|
||
color: tokens.ink,
|
||
}}
|
||
>
|
||
{title}
|
||
</div>
|
||
<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,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
letterSpacing: 1.5,
|
||
color: tokens.ink,
|
||
}}
|
||
>
|
||
СВОДКА ОБЪЕКТА
|
||
</div>
|
||
<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);
|
||
// 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), []);
|
||
|
||
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;
|
||
|
||
// ── 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],
|
||
);
|
||
|
||
// ── 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} />;
|
||
} 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 />;
|
||
}
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
position: "relative",
|
||
width: 1536,
|
||
height: 1024,
|
||
overflow: "hidden",
|
||
background: tokens.gradientBg,
|
||
fontFamily: tokens.font.sans,
|
||
color: tokens.ink,
|
||
}}
|
||
>
|
||
<style>{HELPER_STYLES}</style>
|
||
|
||
{/* 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
|
||
active={nav}
|
||
onNavigate={setNav}
|
||
reports={reportsCount ?? 0}
|
||
user={topNavUser}
|
||
onLogout={logout}
|
||
/>
|
||
<HeroBar
|
||
data={{ report, object: objectInfo }}
|
||
estimateId={mounted ? currentEstimateId : null}
|
||
onOpenInfo={() => setDrawerOpen(true)}
|
||
/>
|
||
|
||
<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>
|
||
|
||
<Footer data={report} />
|
||
</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>
|
||
);
|
||
}
|