From e18bb065397e1425be9fb5001b9abe310995b4dd Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 20 Jun 2026 15:58:50 +0500 Subject: [PATCH] =?UTF-8?q?feat(ptica):=20build=20=D0=9E=D1=82=D1=87=D1=91?= =?UTF-8?q?=D1=82=D1=8B=20+=20=D0=A1=D1=80=D0=B0=D0=B2=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20cockpit=20tabs=20(PR#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reports: dark export cards reusing real infra — PDF snapshot (/snapshot.pdf via shared API_BASE_URL+triggerDownload), client-side CSV (formula-injection guard + UTF-8 BOM preserved), §22 forecast DOCX/PPTX gated on forecast-ready with honest 404; full-report DOCX/PPTX disabled «скоро» (no endpoint). Section checklist mirrors the snapshot TOC. Compare: reuses compare-shortlist (localStorage, COMPARE_MAX) + ParcelCompareLoader for real per-cad analyze metrics (shared cache, rules-of-hooks safe via keyed loader). Current cad auto-pinned + non-removable, validated кадастр input, removable chips. Dark comparison table with winner highlight + colour-coded verdict (Можно/С огранич./Нельзя). Dark styles scoped under .pticaRoot[data-theme=dark]; reuse-only (ExportButtons, compare page, loaders untouched); no new deps. TS strict, no any. --- .../analysis/[cad]/ptica/PticaPageContent.tsx | 11 +- .../analysis/[cad]/ptica/ptica.module.css | 336 ++++++++++++++- .../ptica/compare/PticaCompare.tsx | 406 ++++++++++++++++++ .../ptica/reports/PticaReports.tsx | 379 ++++++++++++++++ 4 files changed, 1123 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/site-finder/ptica/compare/PticaCompare.tsx create mode 100644 frontend/src/components/site-finder/ptica/reports/PticaReports.tsx diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx index 00ac0eea..0a2c00b1 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/PticaPageContent.tsx @@ -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") && ( - - )} + {tab === "reports" && } + {tab === "compare" && } ); diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css index 670d4ff4..6e2eeec2 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css @@ -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; } } diff --git a/frontend/src/components/site-finder/ptica/compare/PticaCompare.tsx b/frontend/src/components/site-finder/ptica/compare/PticaCompare.tsx new file mode 100644 index 00000000..e56a489b --- /dev/null +++ b/frontend/src/components/site-finder/ptica/compare/PticaCompare.tsx @@ -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 = { + Можно: 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 ? ( + {c.verdictLabel} + ) : ( + DASH + ), + }, + { + key: "score", + label: "Инвест-балл", + winner: "high", + value: (c) => c.score ?? null, + render: (c) => ( + <> + {fmtScore(c.score)} + {c.scoreLabel ? ( + · {c.scoreLabel} + ) : 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 { + 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([]); + const [hydrated, setHydrated] = useState(false); + const [input, setInput] = useState(""); + const [inputError, setInputError] = useState(null); + const [columns, setColumns] = useState>({}); + + 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>( + METRIC_ROWS.map((row) => [row.key, winnersFor(row, orderedColumns)]), + ), + [orderedColumns], + ); + + return ( +
+
+

+ Сравнение участков + + shortlist · до {COMPARE_MAX} объектов · COMPARE + +

+
+ + {/* Headless loaders — one per cad, lift analyze metrics up (real flow). */} + {allCads.map((c) => ( + + ))} + + {/* ── Add bar + chips ──────────────────────────────────────────────── */} +
+
+ { + setInput(e.target.value); + setInputError(null); + }} + placeholder="Добавить кадастровый номер · 66:41:…" + aria-label="Кадастровый номер для сравнения" + disabled={atMax} + className={styles.compareInput} + /> + + +
+ + {cad} + текущий + + {extras.map((c) => ( + + {c} + + + ))} +
+
+ {inputError && ( +

+ {inputError} +

+ )} +
+ + {/* ── Comparison table ─────────────────────────────────────────────── */} +
+ {extras.length === 0 ? ( +

+ Добавьте ещё участки, чтобы поставить метрики рядом. Лучшее значение + в строке подсвечивается; список сохраняется между сессиями. +

+ ) : ( + + + + + {orderedColumns.map((c) => ( + + ))} + + + + {METRIC_ROWS.map((row) => { + const winners = winnerByRow.get(row.key) ?? new Set(); + const isVerdict = row.key === "verdict"; + return ( + + + {orderedColumns.map((c) => { + const best = winners.has(c.cad); + return ( + + ); + })} + + ); + })} + +
Показатель + {shortCad(c.cad)} + {c.districtName && c.districtName !== DASH ? ( + + {c.districtName} + + ) : null} +
{row.label} + {c.status === "loading" ? ( + + ) : c.status === "error" ? ( + + ошибка + + ) : ( + row.render(c) + )} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/site-finder/ptica/reports/PticaReports.tsx b/frontend/src/components/site-finder/ptica/reports/PticaReports.tsx new file mode 100644 index 00000000..b0c5e76d --- /dev/null +++ b/frontend/src/components/site-finder/ptica/reports/PticaReports.tsx @@ -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(null); + const [error, setError] = useState(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 ( +
+
+

+ Отчёты + экспорт и генерация · EXPORT +

+
+ +
+ {/* ── Export-format cards ───────────────────────────────────────── */} +
+
+

+ Экспорт FORMATS +

+
+ +
+ {/* PDF snapshot — REAL (/snapshot.pdf) */} + + + {/* CSV — REAL (client-side) */} + + + {/* §22 forecast DOCX — REAL, gated on forecast ready */} + + + {/* §22 forecast PPTX — REAL, gated on forecast ready */} + + + {/* Full-report DOCX — NO endpoint → honest disabled «скоро» */} +
+ DOCX + + + Полный отчёт + скоро + + Редактируемый Word + + +
+ + {/* Full-report PPTX — NO endpoint → honest disabled «скоро» */} +
+ PPTX + + + Презентация + скоро + + Полный отчёт · слайды + + +
+
+ + {error && ( +

+ {error} +

+ )} +
+ + {/* ── Report-section checklist ──────────────────────────────────── */} +
+
+

+ Состав отчёта{" "} + SECTIONS +

+
+ +
+ {REPORT_SECTIONS.map((section, i) => ( +
+ + {String(i + 1).padStart(2, "0")} + + {section.label} + {section.eyebrow} +
+ ))} +
+ +
+ GENDESIGN · PDF снимок + {cad} +
+
+
+
+ ); +} -- 2.45.3