feat(ptica): Отчёты + Сравнение cockpit tabs [PR#3] #1833
4 changed files with 1123 additions and 9 deletions
|
|
@ -27,7 +27,8 @@ import type { PticaTab } from "@/components/site-finder/ptica/PticaShell";
|
||||||
import { PticaHero } from "@/components/site-finder/ptica/PticaHero";
|
import { PticaHero } from "@/components/site-finder/ptica/PticaHero";
|
||||||
import { DevelopmentScan } from "@/components/site-finder/ptica/DevelopmentScan";
|
import { DevelopmentScan } from "@/components/site-finder/ptica/DevelopmentScan";
|
||||||
import { PticaScenarios } from "@/components/site-finder/ptica/scenarios/PticaScenarios";
|
import { PticaScenarios } from "@/components/site-finder/ptica/scenarios/PticaScenarios";
|
||||||
import { PticaPlaceholderPanel } from "@/components/site-finder/ptica/PticaPlaceholderPanel";
|
import { PticaReports } from "@/components/site-finder/ptica/reports/PticaReports";
|
||||||
|
import { PticaCompare } from "@/components/site-finder/ptica/compare/PticaCompare";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cad: string;
|
cad: string;
|
||||||
|
|
@ -93,12 +94,8 @@ export function PticaPageContent({ cad }: Props) {
|
||||||
onHorizonChange={setHorizon}
|
onHorizonChange={setHorizon}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(tab === "reports" || tab === "compare") && (
|
{tab === "reports" && <PticaReports cad={cad} analysis={analysis} />}
|
||||||
<PticaPlaceholderPanel
|
{tab === "compare" && <PticaCompare cad={cad} />}
|
||||||
label="В разработке"
|
|
||||||
hint="Раздел появится в следующем релизе платформы ПТИЦА."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</PticaShell>
|
</PticaShell>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1064,6 +1064,336 @@
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================== REPORTS (Отчёты) ===================== */
|
||||||
|
.reportsRoot,
|
||||||
|
.compareRoot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.reportGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* export-format cards */
|
||||||
|
.exportGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.exportCard {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 14px;
|
||||||
|
border: var(--line) solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: inherit;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s,
|
||||||
|
opacity 0.15s;
|
||||||
|
}
|
||||||
|
.exportCard:hover:not(:disabled):not(.exportCardDisabled) {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
box-shadow: 0 0 14px var(--glow);
|
||||||
|
}
|
||||||
|
.exportCard:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.exportCardDisabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.fmt {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.fmtPdf {
|
||||||
|
background: var(--accent-red);
|
||||||
|
}
|
||||||
|
.fmtCsv {
|
||||||
|
background: var(--accent-green);
|
||||||
|
}
|
||||||
|
.fmtDocx {
|
||||||
|
background: var(--accent-blue);
|
||||||
|
}
|
||||||
|
.fmtPptx {
|
||||||
|
background: var(--accent-orange);
|
||||||
|
}
|
||||||
|
.fmtMuted {
|
||||||
|
background: var(--surface-muted);
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
.exportMeta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.exportTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.exportDesc {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
.exportTag {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
border: var(--line) solid currentColor;
|
||||||
|
}
|
||||||
|
.exportTagSoon {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-soft);
|
||||||
|
border: var(--line) solid var(--border);
|
||||||
|
}
|
||||||
|
.exportIco {
|
||||||
|
flex: none;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
.reportError {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* report-section checklist */
|
||||||
|
.tocList {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.tocItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: var(--line) dashed var(--border-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.tocIndex {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.tocLabel {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.tocEyebrow {
|
||||||
|
font-size: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
.sourceLine {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== COMPARE (Сравнение) ===================== */
|
||||||
|
.compareBar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.compareInput {
|
||||||
|
flex: 0 1 320px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: var(--line) solid var(--border);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.compareInput:focus {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
.compareInput:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.compareAddBtn {
|
||||||
|
padding: 9px 16px;
|
||||||
|
border: var(--line) solid var(--accent-blue);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
opacity 0.15s;
|
||||||
|
}
|
||||||
|
.compareAddBtn:hover:not(:disabled) {
|
||||||
|
background: var(--accent-blue);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.compareAddBtn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.chipRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: var(--line) solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.chip .mono {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.chipPinned {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
.chipTag {
|
||||||
|
font-size: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
.chipRemove {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
.chipRemove:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.compareInputError {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
.compareHint {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compareTableWrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.compareTable {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 11px;
|
||||||
|
min-width: 560px;
|
||||||
|
}
|
||||||
|
.compareTable th,
|
||||||
|
.compareTable td {
|
||||||
|
padding: 9px 11px;
|
||||||
|
text-align: right;
|
||||||
|
border-bottom: var(--line) solid var(--border-faint);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.compareTable th:first-child,
|
||||||
|
.compareTable td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.compareTable thead th {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 11px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.compareTable thead th .mono {
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.compareDistrict {
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
.compareBest {
|
||||||
|
color: var(--accent-green);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.compareVerdictRow td {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.verdictBuy {
|
||||||
|
color: var(--accent-green);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.verdictWatch {
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.verdictSkip {
|
||||||
|
color: var(--accent-red);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.cellMuted {
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
.cellError {
|
||||||
|
color: var(--accent-red);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===================== RESPONSIVE ===================== */
|
/* ===================== RESPONSIVE ===================== */
|
||||||
@media (max-width: 1500px) {
|
@media (max-width: 1500px) {
|
||||||
.hero {
|
.hero {
|
||||||
|
|
@ -1080,7 +1410,8 @@
|
||||||
.scanGrid {
|
.scanGrid {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
.forecastGrid {
|
.forecastGrid,
|
||||||
|
.reportGrid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1102,7 +1433,8 @@
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.scenarioGrid,
|
.scenarioGrid,
|
||||||
.forecastGrid {
|
.forecastGrid,
|
||||||
|
.exportGrid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,406 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PticaCompare — ПТИЦА «Сравнение» tab (PR#3). Dark-styled, but REUSES the real
|
||||||
|
* shortlist + compare data flow rather than inventing data:
|
||||||
|
*
|
||||||
|
* • Shortlist persistence — compare-shortlist.ts (gd_compare_shortlist in
|
||||||
|
* localStorage, isValidCad, COMPARE_MAX). The CURRENT cad is auto-pinned and
|
||||||
|
* can't be removed; the rest are user-managed chips.
|
||||||
|
* • Per-cad metrics — ParcelCompareLoader (the SAME headless loader the
|
||||||
|
* /site-finder/compare page uses), which runs useParcelAnalyzeQuery per cad
|
||||||
|
* and lifts a flattened CompareColumn up. Cache is shared with the analysis
|
||||||
|
* page, so the current parcel is served warm.
|
||||||
|
*
|
||||||
|
* The table is a dark reimplementation (CompareTable ships light tokens) but
|
||||||
|
* consumes the identical CompareColumn shape and the same winner-highlight +
|
||||||
|
* verdict-colour logic. Embedded — not a link-fallback.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
import { ParcelCompareLoader } from "@/components/site-finder/compare/ParcelCompareLoader";
|
||||||
|
import type { CompareColumn } from "@/components/site-finder/compare/CompareTable";
|
||||||
|
import {
|
||||||
|
COMPARE_MAX,
|
||||||
|
getShortlist,
|
||||||
|
isValidCad,
|
||||||
|
saveShortlist,
|
||||||
|
} from "@/lib/compare-shortlist";
|
||||||
|
import type { GateVerdictLabel } from "@/types/site-finder";
|
||||||
|
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cad: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Metric-row model (mirrors compare/CompareTable winner logic) ──────────────
|
||||||
|
|
||||||
|
type WinnerDir = "high" | "low" | "none";
|
||||||
|
|
||||||
|
interface MetricRow {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
winner: WinnerDir;
|
||||||
|
value: (c: CompareColumn) => number | null;
|
||||||
|
render: (c: CompareColumn) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DASH = "—";
|
||||||
|
|
||||||
|
const CONFIDENCE_RU: Record<"high" | "medium" | "low", string> = {
|
||||||
|
high: "высокая",
|
||||||
|
medium: "средняя",
|
||||||
|
low: "низкая",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verdict → semantic colour class (green/amber/red like the prototype).
|
||||||
|
const VERDICT_CLASS: Record<GateVerdictLabel, string> = {
|
||||||
|
Можно: styles.verdictBuy,
|
||||||
|
Нельзя: styles.verdictSkip,
|
||||||
|
"С ограничениями": styles.verdictWatch,
|
||||||
|
"Нужна проверка": styles.verdictWatch,
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtScore(v: number | null | undefined): string {
|
||||||
|
return v != null && Number.isFinite(v) ? v.toFixed(1) : DASH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPrice(v: number | null | undefined): string {
|
||||||
|
return v != null && Number.isFinite(v)
|
||||||
|
? `${Math.round(v).toLocaleString("ru")} ₽`
|
||||||
|
: DASH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPipeline(v: number | null | undefined): string {
|
||||||
|
return v != null && Number.isFinite(v)
|
||||||
|
? `${Math.round(v).toLocaleString("ru")} кв.`
|
||||||
|
: DASH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const METRIC_ROWS: MetricRow[] = [
|
||||||
|
{
|
||||||
|
key: "verdict",
|
||||||
|
label: "Вердикт МКД",
|
||||||
|
winner: "none",
|
||||||
|
value: () => null,
|
||||||
|
render: (c) =>
|
||||||
|
c.verdictLabel ? (
|
||||||
|
<span className={VERDICT_CLASS[c.verdictLabel]}>{c.verdictLabel}</span>
|
||||||
|
) : (
|
||||||
|
DASH
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "score",
|
||||||
|
label: "Инвест-балл",
|
||||||
|
winner: "high",
|
||||||
|
value: (c) => c.score ?? null,
|
||||||
|
render: (c) => (
|
||||||
|
<>
|
||||||
|
{fmtScore(c.score)}
|
||||||
|
{c.scoreLabel ? (
|
||||||
|
<span className={styles.cellMuted}> · {c.scoreLabel}</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "velocity",
|
||||||
|
label: "Скорость продаж",
|
||||||
|
winner: "high",
|
||||||
|
// velocity_score=0 при velocity_data_available=false — это «нет данных»,
|
||||||
|
// а не реальный 0 → исключаем из подсветки и рендерим «—».
|
||||||
|
value: (c) =>
|
||||||
|
c.velocityDataAvailable === false ? null : (c.velocityScore ?? null),
|
||||||
|
render: (c) =>
|
||||||
|
c.velocityDataAvailable !== false &&
|
||||||
|
c.velocityScore != null &&
|
||||||
|
Number.isFinite(c.velocityScore)
|
||||||
|
? `${Math.round(c.velocityScore * 100)}%`
|
||||||
|
: DASH,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "price",
|
||||||
|
label: "Медиана ₽ / м²",
|
||||||
|
// Дешевле ≠ лучше для девелопера — валентность неоднозначна, не подсвечиваем.
|
||||||
|
winner: "none",
|
||||||
|
value: (c) => c.medianPricePerM2 ?? null,
|
||||||
|
render: (c) => fmtPrice(c.medianPricePerM2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "pipeline",
|
||||||
|
label: "Пайплайн 24 мес",
|
||||||
|
// Больше конкурирующего предложения → хуже. Меньше выигрывает.
|
||||||
|
winner: "low",
|
||||||
|
value: (c) => c.pipeline24mo ?? null,
|
||||||
|
render: (c) => fmtPipeline(c.pipeline24mo),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "confidence",
|
||||||
|
label: "Достоверность",
|
||||||
|
winner: "high",
|
||||||
|
value: (c) => c.confidenceValue ?? null,
|
||||||
|
render: (c) =>
|
||||||
|
c.confidenceLabel ? (
|
||||||
|
<>
|
||||||
|
{c.confidenceValue != null
|
||||||
|
? `${Math.round(c.confidenceValue * 100)}% · `
|
||||||
|
: ""}
|
||||||
|
{CONFIDENCE_RU[c.confidenceLabel]}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
DASH
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Cads that win a comparable row (ties win jointly); empty when not comparable. */
|
||||||
|
function winnersFor(row: MetricRow, columns: CompareColumn[]): Set<string> {
|
||||||
|
if (row.winner === "none") return new Set();
|
||||||
|
const vals = columns
|
||||||
|
.map((c) => ({ cad: c.cad, v: row.value(c) }))
|
||||||
|
.filter((x): x is { cad: string; v: number } => x.v != null);
|
||||||
|
if (vals.length < 2) return new Set();
|
||||||
|
const best =
|
||||||
|
row.winner === "high"
|
||||||
|
? Math.max(...vals.map((x) => x.v))
|
||||||
|
: Math.min(...vals.map((x) => x.v));
|
||||||
|
return new Set(vals.filter((x) => x.v === best).map((x) => x.cad));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short tail of a cad for compact column headers («…0204016:10»).
|
||||||
|
function shortCad(cad: string): string {
|
||||||
|
const parts = cad.split(":");
|
||||||
|
return parts.length > 2 ? `…${parts.slice(2).join(":")}` : cad;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function PticaCompare({ cad }: Props) {
|
||||||
|
// Extra cads (beyond the pinned current one), hydrated from localStorage after
|
||||||
|
// mount (SSR-safe: first client render matches the server's empty list).
|
||||||
|
const [extras, setExtras] = useState<string[]>([]);
|
||||||
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [inputError, setInputError] = useState<string | null>(null);
|
||||||
|
const [columns, setColumns] = useState<Record<string, CompareColumn>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// The current cad is always pinned first; the shortlist supplies the rest.
|
||||||
|
setExtras(getShortlist().filter((c) => c !== cad));
|
||||||
|
setHydrated(true);
|
||||||
|
}, [cad]);
|
||||||
|
|
||||||
|
// Persist the union (current pinned + extras) so it survives navigation, but
|
||||||
|
// only after hydration so the initial empty state never clobbers a saved list.
|
||||||
|
useEffect(() => {
|
||||||
|
if (hydrated) saveShortlist([cad, ...extras]);
|
||||||
|
}, [cad, extras, hydrated]);
|
||||||
|
|
||||||
|
const handleResult = useCallback((col: CompareColumn) => {
|
||||||
|
setColumns((prev) => {
|
||||||
|
const existing = prev[col.cad];
|
||||||
|
if (
|
||||||
|
existing &&
|
||||||
|
existing.status === col.status &&
|
||||||
|
existing.errorMsg === col.errorMsg &&
|
||||||
|
existing.districtName === col.districtName &&
|
||||||
|
existing.verdictLabel === col.verdictLabel &&
|
||||||
|
existing.score === col.score &&
|
||||||
|
existing.scoreLabel === col.scoreLabel &&
|
||||||
|
existing.velocityScore === col.velocityScore &&
|
||||||
|
existing.velocityDataAvailable === col.velocityDataAvailable &&
|
||||||
|
existing.medianPricePerM2 === col.medianPricePerM2 &&
|
||||||
|
existing.pipeline24mo === col.pipeline24mo &&
|
||||||
|
existing.confidenceLabel === col.confidenceLabel &&
|
||||||
|
existing.confidenceValue === col.confidenceValue
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return { ...prev, [col.cad]: col };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The pinned current cad counts toward COMPARE_MAX.
|
||||||
|
const atMax = 1 + extras.length >= COMPARE_MAX;
|
||||||
|
|
||||||
|
function addCad(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = input.trim();
|
||||||
|
if (!isValidCad(next)) {
|
||||||
|
setInputError("Неверный формат. Ожидается 66:41:0204016:10.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next === cad || extras.includes(next)) {
|
||||||
|
setInputError("Этот участок уже в сравнении.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (atMax) {
|
||||||
|
setInputError(`Можно сравнить не более ${COMPARE_MAX} участков.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExtras((prev) => [...prev, next]);
|
||||||
|
setInput("");
|
||||||
|
setInputError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCad = useCallback((target: string) => {
|
||||||
|
setExtras((prev) => prev.filter((c) => c !== target));
|
||||||
|
setColumns((prev) => {
|
||||||
|
const nextCols = { ...prev };
|
||||||
|
delete nextCols[target];
|
||||||
|
return nextCols;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Ordered cads: pinned current first, then the user-added extras.
|
||||||
|
const allCads = useMemo(() => [cad, ...extras], [cad, extras]);
|
||||||
|
|
||||||
|
const orderedColumns: CompareColumn[] = useMemo(
|
||||||
|
() =>
|
||||||
|
allCads.map((c) => columns[c] ?? { cad: c, status: "loading" as const }),
|
||||||
|
[allCads, columns],
|
||||||
|
);
|
||||||
|
|
||||||
|
const winnerByRow = useMemo(
|
||||||
|
() =>
|
||||||
|
new Map<string, Set<string>>(
|
||||||
|
METRIC_ROWS.map((row) => [row.key, winnersFor(row, orderedColumns)]),
|
||||||
|
),
|
||||||
|
[orderedColumns],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.compareRoot}>
|
||||||
|
<div className={styles.viewHead}>
|
||||||
|
<h2>
|
||||||
|
Сравнение участков
|
||||||
|
<span className={styles.viewSub}>
|
||||||
|
shortlist · до {COMPARE_MAX} объектов · COMPARE
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Headless loaders — one per cad, lift analyze metrics up (real flow). */}
|
||||||
|
{allCads.map((c) => (
|
||||||
|
<ParcelCompareLoader key={c} cad={c} onResult={handleResult} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* ── Add bar + chips ──────────────────────────────────────────────── */}
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<form className={styles.compareBar} onSubmit={addCad}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInput(e.target.value);
|
||||||
|
setInputError(null);
|
||||||
|
}}
|
||||||
|
placeholder="Добавить кадастровый номер · 66:41:…"
|
||||||
|
aria-label="Кадастровый номер для сравнения"
|
||||||
|
disabled={atMax}
|
||||||
|
className={styles.compareInput}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!input.trim() || atMax}
|
||||||
|
className={styles.compareAddBtn}
|
||||||
|
>
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={styles.chipRow}>
|
||||||
|
<span className={`${styles.chip} ${styles.chipPinned}`}>
|
||||||
|
<span className="mono">{cad}</span>
|
||||||
|
<span className={styles.chipTag}>текущий</span>
|
||||||
|
</span>
|
||||||
|
{extras.map((c) => (
|
||||||
|
<span key={c} className={styles.chip}>
|
||||||
|
<span className="mono">{c}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeCad(c)}
|
||||||
|
aria-label={`Убрать ${c} из сравнения`}
|
||||||
|
className={styles.chipRemove}
|
||||||
|
>
|
||||||
|
<X size={11} strokeWidth={1.6} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{inputError && (
|
||||||
|
<p className={styles.compareInputError} role="alert">
|
||||||
|
{inputError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Comparison table ─────────────────────────────────────────────── */}
|
||||||
|
<div className={`${styles.panel} ${styles.compareTableWrap}`}>
|
||||||
|
{extras.length === 0 ? (
|
||||||
|
<p className={styles.compareHint}>
|
||||||
|
Добавьте ещё участки, чтобы поставить метрики рядом. Лучшее значение
|
||||||
|
в строке подсвечивается; список сохраняется между сессиями.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className={styles.compareTable}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Показатель</th>
|
||||||
|
{orderedColumns.map((c) => (
|
||||||
|
<th key={c.cad} title={c.cad}>
|
||||||
|
<span className="mono">{shortCad(c.cad)}</span>
|
||||||
|
{c.districtName && c.districtName !== DASH ? (
|
||||||
|
<span className={styles.compareDistrict}>
|
||||||
|
{c.districtName}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{METRIC_ROWS.map((row) => {
|
||||||
|
const winners = winnerByRow.get(row.key) ?? new Set<string>();
|
||||||
|
const isVerdict = row.key === "verdict";
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.key}
|
||||||
|
className={isVerdict ? styles.compareVerdictRow : undefined}
|
||||||
|
>
|
||||||
|
<td>{row.label}</td>
|
||||||
|
{orderedColumns.map((c) => {
|
||||||
|
const best = winners.has(c.cad);
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={c.cad}
|
||||||
|
className={best ? styles.compareBest : undefined}
|
||||||
|
>
|
||||||
|
{c.status === "loading" ? (
|
||||||
|
<span className={styles.cellMuted}>…</span>
|
||||||
|
) : c.status === "error" ? (
|
||||||
|
<span
|
||||||
|
className={styles.cellError}
|
||||||
|
title={c.errorMsg ?? "Ошибка анализа"}
|
||||||
|
>
|
||||||
|
ошибка
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
row.render(c)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,379 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PticaReports — ПТИЦА «Отчёты» tab (PR#3). Dark-styled export cockpit that
|
||||||
|
* REUSES the real export infrastructure rather than faking downloads:
|
||||||
|
*
|
||||||
|
* • PDF снимок — GET /api/v1/parcels/{cad}/snapshot.pdf (same endpoint as
|
||||||
|
* analysis/ExportButtons → blob → triggerDownload).
|
||||||
|
* • CSV сырьё — client-side CSV generation from the analyze response
|
||||||
|
* (replicating ExportButtons' buildCsvRows / escapeCsvCell logic; no
|
||||||
|
* backend endpoint, no hardcoded URL).
|
||||||
|
* • §22 .docx / .pptx — GET /api/v1/parcels/{cad}/forecast/export?format=…
|
||||||
|
* (same endpoint as ForecastExportButtons). Gated on the async forecast
|
||||||
|
* being ready (useParcelForecastQuery): until then the cards are disabled
|
||||||
|
* with an honest «готовится» tag.
|
||||||
|
*
|
||||||
|
* Full-report DOCX / PPTX (the prototype's editable-Word / presentation cards)
|
||||||
|
* have NO backend endpoint — the only Word/PowerPoint export the backend ships
|
||||||
|
* is the §22 forecast above. So those two cards render DISABLED with a «скоро»
|
||||||
|
* tag (honest — we never trigger a download that 404s).
|
||||||
|
*
|
||||||
|
* The «Состав отчёта» list is a visual checklist of the PDF snapshot's sections
|
||||||
|
* (matches the prototype TOC); the PDF snapshot is the real multi-section
|
||||||
|
* deliverable behind it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { FileText, Download, Presentation, FileType, Lock } from "lucide-react";
|
||||||
|
|
||||||
|
import { API_BASE_URL } from "@/lib/api";
|
||||||
|
import { triggerDownload } from "@/lib/download";
|
||||||
|
import { useParcelForecastQuery } from "@/lib/site-finder-api";
|
||||||
|
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
|
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cad: string;
|
||||||
|
analysis: ParcelAnalysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CSV generation (replicates analysis/ExportButtons row + escape logic) ─────
|
||||||
|
|
||||||
|
function buildCsvRows(a: ParcelAnalysis): string[][] {
|
||||||
|
return [
|
||||||
|
["Поле", "Значение"],
|
||||||
|
["Кадастровый номер", a.cad_num],
|
||||||
|
["Балл", String(a.score)],
|
||||||
|
["Оценка", a.score_label ?? ""],
|
||||||
|
["Вердикт МКД", a.gate_verdict?.verdict_label ?? ""],
|
||||||
|
["Район", a.district?.district_name ?? ""],
|
||||||
|
[
|
||||||
|
"Медиана ₽/м²",
|
||||||
|
a.district?.median_price_per_m2
|
||||||
|
? String(a.district.median_price_per_m2)
|
||||||
|
: "",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Скорость продаж",
|
||||||
|
a.velocity?.velocity_data_available !== false &&
|
||||||
|
a.velocity?.velocity_score != null
|
||||||
|
? String(a.velocity.velocity_score)
|
||||||
|
: "",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Пайплайн 24 мес (кв.)",
|
||||||
|
a.pipeline_24mo?.flats_total != null
|
||||||
|
? String(a.pipeline_24mo.flats_total)
|
||||||
|
: "",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Достоверность",
|
||||||
|
a.confidence != null ? String(a.confidence) : (a.confidence_label ?? ""),
|
||||||
|
],
|
||||||
|
["POI (кол-во)", String(a.poi_count)],
|
||||||
|
["Конкурентов", String(a.competitors.length)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV-injection mitigation (OWASP / CWE-1236) — same guard as ExportButtons.
|
||||||
|
function escapeCsvCell(cell: string): string {
|
||||||
|
let safe = cell;
|
||||||
|
if (/^[=+\-@\t\r]/.test(safe)) {
|
||||||
|
safe = "'" + safe;
|
||||||
|
}
|
||||||
|
const needsQuotes = /[",\n\r]/.test(safe);
|
||||||
|
if (needsQuotes) {
|
||||||
|
return `"${safe.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCsvBlob(rows: string[][]): Blob {
|
||||||
|
const csvContent = rows
|
||||||
|
.map((row) => row.map(escapeCsvCell).join(","))
|
||||||
|
.join("\r\n");
|
||||||
|
// UTF-8 BOM → Excel-friendly (ui-microcopy export rule).
|
||||||
|
return new Blob(["" + csvContent], { type: "text/csv;charset=utf-8" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cadastral numbers contain ":" — illegal/awkward in filenames on some OSes.
|
||||||
|
function sanitizeCad(cad: string): string {
|
||||||
|
return cad.replace(/:/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Report-section checklist (mirrors the PDF snapshot's TOC) ─────────────────
|
||||||
|
|
||||||
|
interface ReportSection {
|
||||||
|
label: string;
|
||||||
|
eyebrow: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPORT_SECTIONS: ReportSection[] = [
|
||||||
|
{ label: "Паспорт участка", eyebrow: "PASSPORT" },
|
||||||
|
{ label: "Градостроительный потенциал", eyebrow: "BUILDABILITY" },
|
||||||
|
{ label: "Инженерная обеспеченность", eyebrow: "UTILITIES" },
|
||||||
|
{ label: "Рынок и конкуренты", eyebrow: "MARKET" },
|
||||||
|
{ label: "Прогноз §22", eyebrow: "FORECAST" },
|
||||||
|
{ label: "Финансовая модель", eyebrow: "FINANCE" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type DownloadKind = "pdf" | "csv" | "forecast-docx" | "forecast-pptx";
|
||||||
|
|
||||||
|
export function PticaReports({ cad, analysis }: Props) {
|
||||||
|
const [busy, setBusy] = useState<DownloadKind | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: forecastData } = useParcelForecastQuery(cad);
|
||||||
|
const forecastReady = forecastData?.status === "ready";
|
||||||
|
|
||||||
|
async function handlePdf() {
|
||||||
|
setBusy("pdf");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/parcels/${encodeURIComponent(
|
||||||
|
cad,
|
||||||
|
)}/snapshot.pdf`,
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Ошибка сервера ${res.status}`);
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
triggerDownload(blob, `gendesign_${sanitizeCad(cad)}.pdf`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Ошибка загрузки PDF");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCsv() {
|
||||||
|
setBusy("csv");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const rows = buildCsvRows(analysis);
|
||||||
|
triggerDownload(
|
||||||
|
generateCsvBlob(rows),
|
||||||
|
`gendesign_${sanitizeCad(cad)}.csv`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Ошибка генерации CSV");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleForecastExport(format: "docx" | "pptx") {
|
||||||
|
const kind: DownloadKind =
|
||||||
|
format === "docx" ? "forecast-docx" : "forecast-pptx";
|
||||||
|
setBusy(kind);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/parcels/${encodeURIComponent(
|
||||||
|
cad,
|
||||||
|
)}/forecast/export?format=${format}`,
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(
|
||||||
|
res.status === 404
|
||||||
|
? "Прогноз §22 ещё не готов"
|
||||||
|
: "Не удалось получить отчёт",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
triggerDownload(blob, `gendesign_forecast_${sanitizeCad(cad)}.${format}`);
|
||||||
|
} catch {
|
||||||
|
setError("Не удалось получить отчёт");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.reportsRoot}>
|
||||||
|
<div className={styles.viewHead}>
|
||||||
|
<h2>
|
||||||
|
Отчёты
|
||||||
|
<span className={styles.viewSub}>экспорт и генерация · EXPORT</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.reportGrid}>
|
||||||
|
{/* ── Export-format cards ───────────────────────────────────────── */}
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<div className={styles.cardHead}>
|
||||||
|
<h3 className={styles.cardTitle}>
|
||||||
|
Экспорт <span className={styles.cardTitleSub}>FORMATS</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.exportGrid}>
|
||||||
|
{/* PDF snapshot — REAL (/snapshot.pdf) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.exportCard}
|
||||||
|
onClick={() => void handlePdf()}
|
||||||
|
disabled={busy !== null}
|
||||||
|
aria-label="Скачать снимок отчёта в формате PDF"
|
||||||
|
>
|
||||||
|
<span className={`${styles.fmt} ${styles.fmtPdf}`}>PDF</span>
|
||||||
|
<span className={styles.exportMeta}>
|
||||||
|
<span className={styles.exportTitle}>
|
||||||
|
{busy === "pdf" ? "Готовим…" : "Снимок отчёта"}
|
||||||
|
</span>
|
||||||
|
<span className={styles.exportDesc}>Все секции · PDF</span>
|
||||||
|
</span>
|
||||||
|
<FileText
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
className={styles.exportIco}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* CSV — REAL (client-side) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.exportCard}
|
||||||
|
onClick={handleCsv}
|
||||||
|
disabled={busy !== null}
|
||||||
|
aria-label="Скачать сырьё в формате CSV"
|
||||||
|
>
|
||||||
|
<span className={`${styles.fmt} ${styles.fmtCsv}`}>CSV</span>
|
||||||
|
<span className={styles.exportMeta}>
|
||||||
|
<span className={styles.exportTitle}>
|
||||||
|
{busy === "csv" ? "Генерация…" : "Сырьё"}
|
||||||
|
</span>
|
||||||
|
<span className={styles.exportDesc}>Метрики анализа</span>
|
||||||
|
</span>
|
||||||
|
<Download
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
className={styles.exportIco}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* §22 forecast DOCX — REAL, gated on forecast ready */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.exportCard}
|
||||||
|
onClick={() => void handleForecastExport("docx")}
|
||||||
|
disabled={busy !== null || !forecastReady}
|
||||||
|
aria-label="Скачать прогноз §22 в формате Word"
|
||||||
|
>
|
||||||
|
<span className={`${styles.fmt} ${styles.fmtDocx}`}>DOCX</span>
|
||||||
|
<span className={styles.exportMeta}>
|
||||||
|
<span className={styles.exportTitle}>
|
||||||
|
{busy === "forecast-docx" ? "Готовим…" : "Прогноз §22"}
|
||||||
|
{!forecastReady && (
|
||||||
|
<span className={styles.exportTag}>готовится</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.exportDesc}>Word · сценарии</span>
|
||||||
|
</span>
|
||||||
|
<FileType
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
className={styles.exportIco}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* §22 forecast PPTX — REAL, gated on forecast ready */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.exportCard}
|
||||||
|
onClick={() => void handleForecastExport("pptx")}
|
||||||
|
disabled={busy !== null || !forecastReady}
|
||||||
|
aria-label="Скачать прогноз §22 в формате презентации"
|
||||||
|
>
|
||||||
|
<span className={`${styles.fmt} ${styles.fmtPptx}`}>PPTX</span>
|
||||||
|
<span className={styles.exportMeta}>
|
||||||
|
<span className={styles.exportTitle}>
|
||||||
|
{busy === "forecast-pptx" ? "Готовим…" : "Прогноз §22"}
|
||||||
|
{!forecastReady && (
|
||||||
|
<span className={styles.exportTag}>готовится</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.exportDesc}>Для инвесткомитета</span>
|
||||||
|
</span>
|
||||||
|
<Presentation
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
className={styles.exportIco}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Full-report DOCX — NO endpoint → honest disabled «скоро» */}
|
||||||
|
<div
|
||||||
|
className={`${styles.exportCard} ${styles.exportCardDisabled}`}
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
<span className={`${styles.fmt} ${styles.fmtMuted}`}>DOCX</span>
|
||||||
|
<span className={styles.exportMeta}>
|
||||||
|
<span className={styles.exportTitle}>
|
||||||
|
Полный отчёт
|
||||||
|
<span className={styles.exportTagSoon}>скоро</span>
|
||||||
|
</span>
|
||||||
|
<span className={styles.exportDesc}>Редактируемый Word</span>
|
||||||
|
</span>
|
||||||
|
<Lock size={16} strokeWidth={1.5} className={styles.exportIco} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full-report PPTX — NO endpoint → honest disabled «скоро» */}
|
||||||
|
<div
|
||||||
|
className={`${styles.exportCard} ${styles.exportCardDisabled}`}
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
<span className={`${styles.fmt} ${styles.fmtMuted}`}>PPTX</span>
|
||||||
|
<span className={styles.exportMeta}>
|
||||||
|
<span className={styles.exportTitle}>
|
||||||
|
Презентация
|
||||||
|
<span className={styles.exportTagSoon}>скоро</span>
|
||||||
|
</span>
|
||||||
|
<span className={styles.exportDesc}>Полный отчёт · слайды</span>
|
||||||
|
</span>
|
||||||
|
<Lock size={16} strokeWidth={1.5} className={styles.exportIco} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className={styles.reportError} role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Report-section checklist ──────────────────────────────────── */}
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<div className={styles.cardHead}>
|
||||||
|
<h3 className={styles.cardTitle}>
|
||||||
|
Состав отчёта{" "}
|
||||||
|
<span className={styles.cardTitleSub}>SECTIONS</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.tocList}>
|
||||||
|
{REPORT_SECTIONS.map((section, i) => (
|
||||||
|
<div key={section.eyebrow} className={styles.tocItem}>
|
||||||
|
<span className={styles.tocIndex}>
|
||||||
|
{String(i + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<span className={styles.tocLabel}>{section.label}</span>
|
||||||
|
<span className={styles.tocEyebrow}>{section.eyebrow}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.sourceLine}>
|
||||||
|
<span>GENDESIGN · PDF снимок</span>
|
||||||
|
<span className="mono">{cad}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue