feat(site-finder): horizon selector (6/12/18) on analysis screen (#996)
Segmented control «Горизонт прогноза» driving the analyze query (?horizon=) and Section 6's target highlight. Default 12. - HorizonSelector: radiogroup + a11y radios, tokens-only, tabular-nums, disabled while a re-analyze is in flight - useParcelAnalyzeQuery(cad, horizon=12): horizon in queryKey + ?horizon=; keepPreviousData so switching horizon doesn't blank the page to loading - AnalysisPageContent: horizon state + forecast-poll invalidation on change - Section6Forecast/ForecastHorizonsBlock: selectedHorizon → target row («Целевой») Part of EPIC #958.
This commit is contained in:
parent
4ec762a202
commit
740210d9ea
5 changed files with 189 additions and 16 deletions
|
|
@ -1,7 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { HorizonSelector } from "@/components/site-finder/HorizonSelector";
|
||||||
import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo";
|
import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo";
|
||||||
import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities";
|
import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities";
|
||||||
import { Section3SettingsAndCompetitors } from "@/components/site-finder/analysis/Section3SettingsAndCompetitors";
|
import { Section3SettingsAndCompetitors } from "@/components/site-finder/analysis/Section3SettingsAndCompetitors";
|
||||||
|
|
@ -20,7 +23,23 @@ interface Props {
|
||||||
// ── Page Content (client — needs TanStack Query) ───────────────────────────────
|
// ── Page Content (client — needs TanStack Query) ───────────────────────────────
|
||||||
|
|
||||||
export function AnalysisPageContent({ cad }: Props) {
|
export function AnalysisPageContent({ cad }: Props) {
|
||||||
const { data, isLoading, error } = useParcelAnalyzeQuery(cad);
|
const [horizon, setHorizon] = useState<number>(12);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data, isLoading, isFetching, error } = useParcelAnalyzeQuery(
|
||||||
|
cad,
|
||||||
|
horizon,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Changing the horizon re-runs /analyze (expensive ~10-30s), which re-enqueues
|
||||||
|
// the async §22 forecast (recompute ~30-180s). Invalidating ["parcel-forecast",
|
||||||
|
// cad] forces Section 6's poll to re-fetch; until the new run is ready it may
|
||||||
|
// still show the previous run's emphasis. The selectedHorizon prop passed to
|
||||||
|
// Section6Forecast gives an instant client-side target highlight regardless —
|
||||||
|
// that's intended.
|
||||||
|
function handleHorizonChange(h: number) {
|
||||||
|
setHorizon(h);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["parcel-forecast", cad] });
|
||||||
|
}
|
||||||
|
|
||||||
// ── Loading ────────────────────────────────────────────────────────────────
|
// ── Loading ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -98,6 +117,22 @@ export function AnalysisPageContent({ cad }: Props) {
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<Breadcrumb cad={cad} districtName={districtName} areaStr={areaStr} />
|
<Breadcrumb cad={cad} districtName={districtName} areaStr={areaStr} />
|
||||||
|
|
||||||
|
{/* Controls row — horizon selector drives the analyze + forecast queries */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HorizonSelector
|
||||||
|
value={horizon}
|
||||||
|
onChange={handleHorizonChange}
|
||||||
|
disabled={isFetching}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Page layout: sidebar (240px) + main scroll */}
|
{/* Page layout: sidebar (240px) + main scroll */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -137,7 +172,7 @@ export function AnalysisPageContent({ cad }: Props) {
|
||||||
<Section5Atmosphere cad={cad} />
|
<Section5Atmosphere cad={cad} />
|
||||||
|
|
||||||
{/* Section 6 — IMPLEMENTED in 958-B3 (§22 forecast) */}
|
{/* Section 6 — IMPLEMENTED in 958-B3 (§22 forecast) */}
|
||||||
<Section6Forecast cad={cad} />
|
<Section6Forecast cad={cad} selectedHorizon={horizon} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
85
frontend/src/components/site-finder/HorizonSelector.tsx
Normal file
85
frontend/src/components/site-finder/HorizonSelector.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HorizonSelector — segmented control «Горизонт прогноза» (958-B1 / #996).
|
||||||
|
*
|
||||||
|
* Controlled segmented control over the three forecast horizons accepted by
|
||||||
|
* POST /api/v1/parcels/{cad}/analyze?horizon= (6 / 12 / 18; default 12).
|
||||||
|
* Drives useParcelAnalyzeQuery on the Site Finder analysis screen.
|
||||||
|
*
|
||||||
|
* A11y: role="radiogroup" wrapper + role="radio"/aria-checked per <button>
|
||||||
|
* segment (real buttons → native keyboard focus; visible focus ring kept).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HORIZONS = [6, 12, 18] as const;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Currently selected horizon (one of 6 / 12 / 18). */
|
||||||
|
value: number;
|
||||||
|
/** Called with the newly selected horizon. */
|
||||||
|
onChange: (horizon: number) => void;
|
||||||
|
/** Disable the control while a re-analyze is in flight (prevents spamming). */
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HorizonSelector({ value, onChange, disabled = false }: Props) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Горизонт прогноза
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Горизонт прогноза"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid var(--border-card)",
|
||||||
|
background: "var(--bg-card)",
|
||||||
|
padding: 2,
|
||||||
|
gap: 2,
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{HORIZONS.map((h) => {
|
||||||
|
const selected = h === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={h}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(h)}
|
||||||
|
style={{
|
||||||
|
appearance: "none",
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
padding: "6px 16px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "none",
|
||||||
|
background: selected ? "var(--accent)" : "transparent",
|
||||||
|
color: selected ? "var(--fg-on-dark)" : "var(--fg-secondary)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: selected ? 600 : 500,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
transition: "background 0.12s, color 0.12s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{h} мес
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,8 @@ import {
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
forecasts: DemandSupplyForecast[];
|
forecasts: DemandSupplyForecast[];
|
||||||
|
/** Horizon (мес) to emphasise as the current target — highlights its row. */
|
||||||
|
targetHorizon?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TH_STYLE: React.CSSProperties = {
|
const TH_STYLE: React.CSSProperties = {
|
||||||
|
|
@ -48,7 +50,7 @@ const TD_STYLE: React.CSSProperties = {
|
||||||
|
|
||||||
const NUM_TD: React.CSSProperties = { ...TD_STYLE, textAlign: "right" };
|
const NUM_TD: React.CSSProperties = { ...TD_STYLE, textAlign: "right" };
|
||||||
|
|
||||||
export function ForecastHorizonsBlock({ forecasts }: Props) {
|
export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
|
||||||
if (forecasts.length === 0) {
|
if (forecasts.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p style={{ fontSize: 13, color: "var(--fg-tertiary)", margin: 0 }}>
|
<p style={{ fontSize: 13, color: "var(--fg-tertiary)", margin: 0 }}>
|
||||||
|
|
@ -89,9 +91,37 @@ export function ForecastHorizonsBlock({ forecasts }: Props) {
|
||||||
{rows.map((f) => {
|
{rows.map((f) => {
|
||||||
const di = f.deficit_index;
|
const di = f.deficit_index;
|
||||||
const balanced = Math.abs(di) < DEFICIT_BALANCE_EPS;
|
const balanced = Math.abs(di) < DEFICIT_BALANCE_EPS;
|
||||||
|
const isTarget = f.horizon_months === targetHorizon;
|
||||||
return (
|
return (
|
||||||
<tr key={f.horizon_months}>
|
<tr
|
||||||
<td style={TD_STYLE}>{f.horizon_months} мес.</td>
|
key={f.horizon_months}
|
||||||
|
style={
|
||||||
|
isTarget ? { background: "var(--accent-soft)" } : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={
|
||||||
|
isTarget
|
||||||
|
? { ...TD_STYLE, fontWeight: 600, color: "var(--accent)" }
|
||||||
|
: TD_STYLE
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{f.horizon_months} мес.
|
||||||
|
{isTarget && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--accent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Целевой
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td style={TD_STYLE}>
|
<td style={TD_STYLE}>
|
||||||
<Badge variant={deficitVariant(di)} size="sm">
|
<Badge variant={deficitVariant(di)} size="sm">
|
||||||
{di > 0 ? "+" : ""}
|
{di > 0 ? "+" : ""}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,13 @@ import { CONFIDENCE_RU, deficitVariant, fmtNum } from "./forecast-helpers";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cad: string;
|
cad: string;
|
||||||
|
/**
|
||||||
|
* Horizon chosen in the analysis-screen HorizonSelector (6 / 12 / 18). When
|
||||||
|
* set it takes priority over the report-derived horizon so the user's choice
|
||||||
|
* instantly drives which horizon is treated as the target (client-side), even
|
||||||
|
* before the recomputed forecast finishes (see AnalysisPageContent comment).
|
||||||
|
*/
|
||||||
|
selectedHorizon?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ADVISORY_DISCLAIMER =
|
const ADVISORY_DISCLAIMER =
|
||||||
|
|
@ -48,7 +55,7 @@ function kpiColorForDeficit(deficitIndex: number): "green" | "red" | "neutral" {
|
||||||
|
|
||||||
// ── Section6Forecast ─────────────────────────────────────────────────────────
|
// ── Section6Forecast ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function Section6Forecast({ cad }: Props) {
|
export function Section6Forecast({ cad, selectedHorizon }: Props) {
|
||||||
const { data, isLoading, error } = useParcelForecastQuery(cad);
|
const { data, isLoading, error } = useParcelForecastQuery(cad);
|
||||||
|
|
||||||
const isPending =
|
const isPending =
|
||||||
|
|
@ -136,18 +143,26 @@ export function Section6Forecast({ cad }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ForecastReady report={data.report} />;
|
return <ForecastReady report={data.report} selectedHorizon={selectedHorizon} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ForecastReady (report present) ───────────────────────────────────────────
|
// ── ForecastReady (report present) ───────────────────────────────────────────
|
||||||
|
|
||||||
function ForecastReady({ report }: { report: ForecastReport }) {
|
function ForecastReady({
|
||||||
|
report,
|
||||||
|
selectedHorizon,
|
||||||
|
}: {
|
||||||
|
report: ForecastReport;
|
||||||
|
selectedHorizon?: number;
|
||||||
|
}) {
|
||||||
const exec = report.exec_summary;
|
const exec = report.exec_summary;
|
||||||
const fm = report.future_market;
|
const fm = report.future_market;
|
||||||
|
|
||||||
// Target horizon: prefer future_supply.horizon_months, else median of meta
|
// Target horizon: the user's HorizonSelector choice wins (instant client-side
|
||||||
// horizons, else 12.
|
// highlight), else future_supply.horizon_months, else median of meta horizons,
|
||||||
|
// else 12.
|
||||||
const targetHorizon =
|
const targetHorizon =
|
||||||
|
selectedHorizon ??
|
||||||
fm.future_supply?.horizon_months ??
|
fm.future_supply?.horizon_months ??
|
||||||
medianHorizon(report.meta.horizons) ??
|
medianHorizon(report.meta.horizons) ??
|
||||||
12;
|
12;
|
||||||
|
|
@ -267,7 +282,10 @@ function ForecastReady({ report }: { report: ForecastReport }) {
|
||||||
{/* 6.1 Прогноз по горизонтам */}
|
{/* 6.1 Прогноз по горизонтам */}
|
||||||
{hasHorizons && (
|
{hasHorizons && (
|
||||||
<SubBlock id="section-6-1" title="6.1 Прогноз по горизонтам">
|
<SubBlock id="section-6-1" title="6.1 Прогноз по горизонтам">
|
||||||
<ForecastHorizonsBlock forecasts={fm.forecasts_by_horizon} />
|
<ForecastHorizonsBlock
|
||||||
|
forecasts={fm.forecasts_by_horizon}
|
||||||
|
targetHorizon={targetHorizon}
|
||||||
|
/>
|
||||||
</SubBlock>
|
</SubBlock>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
* MOCK_POI_SCORE — uses poi-score.json fixture (B6)
|
* MOCK_POI_SCORE — uses poi-score.json fixture (B6)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
import { apiFetch, apiFetchWithStatus } from "@/lib/api";
|
import { apiFetch, apiFetchWithStatus } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
MOCK_PARCELS_BBOX,
|
MOCK_PARCELS_BBOX,
|
||||||
|
|
@ -326,21 +326,26 @@ export interface PoiScoreResponse {
|
||||||
|
|
||||||
// ── Hook: useParcelAnalyzeQuery (B5) ─────────────────────────────────────────
|
// ── Hook: useParcelAnalyzeQuery (B5) ─────────────────────────────────────────
|
||||||
|
|
||||||
export function useParcelAnalyzeQuery(cad: string) {
|
export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["parcel-analyze", cad],
|
queryKey: ["parcel-analyze", cad, horizon],
|
||||||
queryFn: async (): Promise<ParcelAnalyzeResponse> => {
|
queryFn: async (): Promise<ParcelAnalyzeResponse> => {
|
||||||
if (MOCK_ANALYZE) {
|
if (MOCK_ANALYZE) {
|
||||||
// Return fixture data regardless of cad — for dev only
|
// Return fixture data regardless of cad/horizon — for dev only
|
||||||
return fixtureAnalyze as ParcelAnalyzeResponse;
|
return fixtureAnalyze as ParcelAnalyzeResponse;
|
||||||
}
|
}
|
||||||
|
// Backend accepts ?horizon= ∈ {6,12,18} (default 12; 422 otherwise, #995).
|
||||||
return apiFetch<ParcelAnalyzeResponse>(
|
return apiFetch<ParcelAnalyzeResponse>(
|
||||||
`/api/v1/parcels/${encodeURIComponent(cad)}/analyze`,
|
`/api/v1/parcels/${encodeURIComponent(cad)}/analyze?horizon=${horizon}`,
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60_000, // 5 min — analyze is expensive
|
staleTime: 5 * 60_000, // 5 min — analyze is expensive
|
||||||
retry: 1,
|
retry: 1,
|
||||||
|
// Horizon is in the queryKey, so switching it is a fresh cache entry. Keep
|
||||||
|
// the previous analysis on screen while the new horizon re-fetches instead
|
||||||
|
// of blanking the whole page to the loading state on every selector change.
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue