gendesign/frontend/src/components/site-finder/ptica/scenarios/RecommendedProduct.tsx
Light1YT 88865bf6a7
All checks were successful
CI / changes (pull_request) Successful in 5s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 57s
CI / openapi-codegen-check (pull_request) Successful in 1m52s
fix(ptica): drawer modal a11y (focus-trap + restore) + polish
PticaDrawer: proper modal focus management —
- focus-trap: Tab/Shift+Tab wrap inside the dialog (focusables re-queried per
  Tab so it survives lazy panels/sub-tabs), focus-already-outside pulled back in;
- focus-restore: the trigger element is captured on open and re-focused on close
  (guards null + detached node);
- onClose held in a ref so the effect depends on [open] only — fixes focus
  thrashing when the host's URL/searchParams change mid-open.
Esc/scrim close, body-scroll-lock, role=dialog/aria-modal preserved.

Cleanups (code-review follow-ups):
- MassingScene: drop duplicate initial mass build (floors/sections effect owns
  it); corrected the effect-ordering comment.
- Static inline styles → CSS-module classes (scan/gauge/score/spaced rows).
- DrawerPrimitives: remove !important on .dtabActive via specificity
  (.dtabs button.dtabActive).
- Remove unused SoonCard component + its CSS.

tsc/lint/prettier clean; next build green (route bundled). ptica-only, no any.
2026-06-20 20:13:26 +05:00

142 lines
4.5 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";
/**
* RecommendedProduct — 6.4 «Рекомендованный продукт». §13.4 product_tz: the
* recommended obj_class (tag) + квартирография as horizontal bars (доля when an
* explicit `pct` mix exists, else |deficit_index| as signal strength — the
* current overlay shape), plus the overall product success-score (scoring.overall)
* and the optional target price when the caller supplies it.
*
* GRACEFUL: returns null when product_tz carries nothing meaningful.
*/
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import type { ProductMixEntry, ReportProductTz } from "@/types/forecast";
import { DEFICIT_BALANCE_EPS, deficitTone, fmtNum } from "./scenario-helpers";
interface Props {
productTz: ReportProductTz;
/** Overall product success-score ∈ 0..1 (scoring.overall) — null when absent. */
successScore: number | null;
/** Target price (₽/м²) when the caller can supply it (district median) — optional. */
targetPrice: string | null;
}
const TONE_CLASS = {
good: styles.hbarFillGood,
bad: styles.hbarFillBad,
warn: styles.hbarFillBad,
neutral: styles.hbarFillNeutral,
} as const;
function isNonEmpty(productTz: ReportProductTz): boolean {
return (
productTz.obj_class != null ||
productTz.mix.length > 0 ||
!!productTz.summary
);
}
function mixLabel(m: ProductMixEntry): string {
return m.bucket ?? "формат";
}
interface MixBar {
key: string;
label: string;
widthPct: number;
valueStr: string;
tone: "good" | "warn" | "bad" | "neutral";
}
function buildBars(mix: ProductMixEntry[]): {
bars: MixBar[];
byShare: boolean;
} {
const rows = mix.filter((m) => m.pct != null || m.deficit_index != null);
const byShare = rows.some((m) => m.pct != null);
const bars = rows.map((m, i): MixBar => {
if (m.pct != null) {
const pct = Math.max(0, Math.min(100, m.pct));
return {
key: `${m.bucket ?? "?"}-${m.obj_class ?? "?"}-${i}`,
label: mixLabel(m),
widthPct: pct,
valueStr: `${fmtNum(pct, pct % 1 === 0 ? 0 : 1)}%`,
tone: "neutral",
};
}
const di = m.deficit_index ?? 0;
const balanced = Math.abs(di) < DEFICIT_BALANCE_EPS;
return {
key: `${m.bucket ?? "?"}-${m.obj_class ?? "?"}-${i}`,
label: mixLabel(m),
widthPct: Math.max(0, Math.min(100, Math.abs(di) * 100)),
valueStr: `${di > 0 ? "+" : ""}${fmtNum(di, 2)}`,
tone: balanced ? "neutral" : deficitTone(di),
};
});
return { bars, byShare };
}
export function RecommendedProduct({
productTz,
successScore,
targetPrice,
}: Props) {
if (!isNonEmpty(productTz)) return null;
const { bars, byShare } = buildBars(productTz.mix);
return (
<div className={styles.panel}>
<div className={styles.cardHead}>
<h3 className={styles.cardTitle}>Рекомендованный продукт</h3>
{productTz.obj_class && (
<span className={styles.tag}>{capitalize(productTz.obj_class)}</span>
)}
</div>
{bars.length > 0 && (
<div className={styles.hbars}>
{bars.map((b) => (
<div key={b.key} className={styles.hbar}>
<span className={styles.hbarName}>{b.label}</span>
<div className={styles.hbarTrack}>
<div
className={`${styles.hbarFill} ${TONE_CLASS[b.tone]}`}
style={{ width: `${b.widthPct}%` }}
/>
</div>
<span className={styles.hbarValue}>{b.valueStr}</span>
</div>
))}
</div>
)}
{!byShare && bars.length > 0 && (
<p className={styles.panelHint}>
Длина полосы сила сигнала по индексу дефицита формата: зелёный
недонасыщенность, красный затоварка.
</p>
)}
{targetPrice && (
<div className={`${styles.scKvRow} ${styles.scKvRowSpaced}`}>
<span className={styles.scK}>Целевая цена</span>
<span className={styles.scV}>{targetPrice}</span>
</div>
)}
<div className={styles.scKvRow}>
<span className={styles.scK}>Success-score формата</span>
<span className={styles.scV}>
{successScore != null ? fmtNum(successScore, 2) : "—"}
</span>
</div>
</div>
);
}
function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}