Merge pull request 'feat(ptica): Отчёты + Сравнение cockpit tabs [PR#3]' (#1833) from feat/ptica-cockpit-pr3-reports-compare into main
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Has been cancelled

Reviewed-on: #1833
This commit is contained in:
lekss361 2026-06-20 12:33:18 +00:00
commit 708d8b5aed
4 changed files with 1123 additions and 9 deletions

View file

@ -27,7 +27,8 @@ import type { PticaTab } from "@/components/site-finder/ptica/PticaShell";
import { PticaHero } from "@/components/site-finder/ptica/PticaHero";
import { DevelopmentScan } from "@/components/site-finder/ptica/DevelopmentScan";
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 {
cad: string;
@ -93,12 +94,8 @@ export function PticaPageContent({ cad }: Props) {
onHorizonChange={setHorizon}
/>
)}
{(tab === "reports" || tab === "compare") && (
<PticaPlaceholderPanel
label="В разработке"
hint="Раздел появится в следующем релизе платформы ПТИЦА."
/>
)}
{tab === "reports" && <PticaReports cad={cad} analysis={analysis} />}
{tab === "compare" && <PticaCompare cad={cad} />}
</PticaShell>
</div>
);

View file

@ -1064,6 +1064,336 @@
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 ===================== */
@media (max-width: 1500px) {
.hero {
@ -1080,7 +1410,8 @@
.scanGrid {
grid-template-columns: repeat(2, 1fr);
}
.forecastGrid {
.forecastGrid,
.reportGrid {
grid-template-columns: 1fr;
}
}
@ -1102,7 +1433,8 @@
display: none;
}
.scenarioGrid,
.forecastGrid {
.forecastGrid,
.exportGrid {
grid-template-columns: 1fr;
}
}

View file

@ -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>
);
}

View file

@ -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>
);
}