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";
|
||||
|
||||
import { useState } from "react";
|
||||
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 { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities";
|
||||
import { Section3SettingsAndCompetitors } from "@/components/site-finder/analysis/Section3SettingsAndCompetitors";
|
||||
|
|
@ -20,7 +23,23 @@ interface Props {
|
|||
// ── Page Content (client — needs TanStack Query) ───────────────────────────────
|
||||
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -98,6 +117,22 @@ export function AnalysisPageContent({ cad }: Props) {
|
|||
{/* Breadcrumb */}
|
||||
<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 */}
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -137,7 +172,7 @@ export function AnalysisPageContent({ cad }: Props) {
|
|||
<Section5Atmosphere cad={cad} />
|
||||
|
||||
{/* Section 6 — IMPLEMENTED in 958-B3 (§22 forecast) */}
|
||||
<Section6Forecast cad={cad} />
|
||||
<Section6Forecast cad={cad} selectedHorizon={horizon} />
|
||||
</div>
|
||||
</div>
|
||||
</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 {
|
||||
forecasts: DemandSupplyForecast[];
|
||||
/** Horizon (мес) to emphasise as the current target — highlights its row. */
|
||||
targetHorizon?: number;
|
||||
}
|
||||
|
||||
const TH_STYLE: React.CSSProperties = {
|
||||
|
|
@ -48,7 +50,7 @@ const TD_STYLE: React.CSSProperties = {
|
|||
|
||||
const NUM_TD: React.CSSProperties = { ...TD_STYLE, textAlign: "right" };
|
||||
|
||||
export function ForecastHorizonsBlock({ forecasts }: Props) {
|
||||
export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
|
||||
if (forecasts.length === 0) {
|
||||
return (
|
||||
<p style={{ fontSize: 13, color: "var(--fg-tertiary)", margin: 0 }}>
|
||||
|
|
@ -89,9 +91,37 @@ export function ForecastHorizonsBlock({ forecasts }: Props) {
|
|||
{rows.map((f) => {
|
||||
const di = f.deficit_index;
|
||||
const balanced = Math.abs(di) < DEFICIT_BALANCE_EPS;
|
||||
const isTarget = f.horizon_months === targetHorizon;
|
||||
return (
|
||||
<tr key={f.horizon_months}>
|
||||
<td style={TD_STYLE}>{f.horizon_months} мес.</td>
|
||||
<tr
|
||||
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}>
|
||||
<Badge variant={deficitVariant(di)} size="sm">
|
||||
{di > 0 ? "+" : ""}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@ import { CONFIDENCE_RU, deficitVariant, fmtNum } from "./forecast-helpers";
|
|||
|
||||
interface Props {
|
||||
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 =
|
||||
|
|
@ -48,7 +55,7 @@ function kpiColorForDeficit(deficitIndex: number): "green" | "red" | "neutral" {
|
|||
|
||||
// ── Section6Forecast ─────────────────────────────────────────────────────────
|
||||
|
||||
export function Section6Forecast({ cad }: Props) {
|
||||
export function Section6Forecast({ cad, selectedHorizon }: Props) {
|
||||
const { data, isLoading, error } = useParcelForecastQuery(cad);
|
||||
|
||||
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) ───────────────────────────────────────────
|
||||
|
||||
function ForecastReady({ report }: { report: ForecastReport }) {
|
||||
function ForecastReady({
|
||||
report,
|
||||
selectedHorizon,
|
||||
}: {
|
||||
report: ForecastReport;
|
||||
selectedHorizon?: number;
|
||||
}) {
|
||||
const exec = report.exec_summary;
|
||||
const fm = report.future_market;
|
||||
|
||||
// Target horizon: prefer future_supply.horizon_months, else median of meta
|
||||
// horizons, else 12.
|
||||
// Target horizon: the user's HorizonSelector choice wins (instant client-side
|
||||
// highlight), else future_supply.horizon_months, else median of meta horizons,
|
||||
// else 12.
|
||||
const targetHorizon =
|
||||
selectedHorizon ??
|
||||
fm.future_supply?.horizon_months ??
|
||||
medianHorizon(report.meta.horizons) ??
|
||||
12;
|
||||
|
|
@ -267,7 +282,10 @@ function ForecastReady({ report }: { report: ForecastReport }) {
|
|||
{/* 6.1 Прогноз по горизонтам */}
|
||||
{hasHorizons && (
|
||||
<SubBlock id="section-6-1" title="6.1 Прогноз по горизонтам">
|
||||
<ForecastHorizonsBlock forecasts={fm.forecasts_by_horizon} />
|
||||
<ForecastHorizonsBlock
|
||||
forecasts={fm.forecasts_by_horizon}
|
||||
targetHorizon={targetHorizon}
|
||||
/>
|
||||
</SubBlock>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
* 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 {
|
||||
MOCK_PARCELS_BBOX,
|
||||
|
|
@ -326,21 +326,26 @@ export interface PoiScoreResponse {
|
|||
|
||||
// ── Hook: useParcelAnalyzeQuery (B5) ─────────────────────────────────────────
|
||||
|
||||
export function useParcelAnalyzeQuery(cad: string) {
|
||||
export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
|
||||
return useQuery({
|
||||
queryKey: ["parcel-analyze", cad],
|
||||
queryKey: ["parcel-analyze", cad, horizon],
|
||||
queryFn: async (): Promise<ParcelAnalyzeResponse> => {
|
||||
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;
|
||||
}
|
||||
// Backend accepts ?horizon= ∈ {6,12,18} (default 12; 422 otherwise, #995).
|
||||
return apiFetch<ParcelAnalyzeResponse>(
|
||||
`/api/v1/parcels/${encodeURIComponent(cad)}/analyze`,
|
||||
`/api/v1/parcels/${encodeURIComponent(cad)}/analyze?horizon=${horizon}`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
},
|
||||
staleTime: 5 * 60_000, // 5 min — analyze is expensive
|
||||
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