From 51a0ae526edb2644bf4e969473d5c34e2345341a Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 7 Jun 2026 13:40:34 +0500 Subject: [PATCH] feat(site-finder): forecast export control in Section 6 (#959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit «Скачать отчёт .md / .json» + «Скопировать для Telegram» on the ready §22 forecast, wired to GET /parcels/{cad}/forecast/export. triggerDownload extracted to lib/download.ts (reused by ExportButtons). Clipboard fallback for insecure contexts; 404 → inline «Отчёт ещё не готов». Part of EPIC #959. --- .../site-finder/analysis/ExportButtons.tsx | 12 +- .../analysis/ForecastExportButtons.tsx | 243 ++++++++++++++++++ .../site-finder/analysis/Section6Forecast.tsx | 14 +- frontend/src/lib/download.ts | 21 ++ 4 files changed, 278 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/site-finder/analysis/ForecastExportButtons.tsx create mode 100644 frontend/src/lib/download.ts diff --git a/frontend/src/components/site-finder/analysis/ExportButtons.tsx b/frontend/src/components/site-finder/analysis/ExportButtons.tsx index b0aadf7f..f522dfb6 100644 --- a/frontend/src/components/site-finder/analysis/ExportButtons.tsx +++ b/frontend/src/components/site-finder/analysis/ExportButtons.tsx @@ -11,6 +11,7 @@ import { useState } from "react"; import { Download, FileText } from "lucide-react"; import { API_BASE_URL } from "@/lib/api"; +import { triggerDownload } from "@/lib/download"; import type { ParcelAnalyzeResponse, ParcelEgrn } from "@/lib/site-finder-api"; interface Props { @@ -76,17 +77,6 @@ function generateCsvBlob(rows: string[][]): Blob { return new Blob(["" + csvContent], { type: "text/csv;charset=utf-8" }); } -function triggerDownload(blob: Blob, filename: string): void { - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - // ── Component ───────────────────────────────────────────────────────────────── export function ExportButtons({ cad, analyzeData }: Props) { diff --git a/frontend/src/components/site-finder/analysis/ForecastExportButtons.tsx b/frontend/src/components/site-finder/analysis/ForecastExportButtons.tsx new file mode 100644 index 00000000..4cf9979e --- /dev/null +++ b/frontend/src/components/site-finder/analysis/ForecastExportButtons.tsx @@ -0,0 +1,243 @@ +"use client"; + +/** + * ForecastExportButtons — export/share control for the §22 forecast (EPIC #959). + * + * Rendered inside Section6Forecast's ForecastReady (forecast status === "ready"). + * Click-triggered download/share ACTIONS — imperative fetch in the handler is the + * right call here (TanStack Query is for declarative data, not user-initiated + * downloads), mirroring ExportButtons. + * + * Backend (live on prod): + * GET /api/v1/parcels/{cad}/forecast/export?format=md → text/markdown attachment + * GET /api/v1/parcels/{cad}/forecast/export?format=json → application/json attachment + * GET /api/v1/parcels/{cad}/forecast/export?format=tg → text/plain inline (TG snippet) + * No forecast run → 404 → inline «Отчёт ещё не готов» (no crash, no console spam). + */ + +import { useEffect, useRef, useState } from "react"; +import { Download, Copy, Check } from "lucide-react"; + +import { API_BASE_URL } from "@/lib/api"; +import { triggerDownload } from "@/lib/download"; + +interface Props { + cad: string; +} + +type ExportFormat = "md" | "json"; + +const NOT_READY_MSG = "Отчёт ещё не готов"; +const GENERIC_ERR_MSG = "Не удалось получить отчёт"; +const COPIED_FEEDBACK_MS = 2000; + +// Cadastral numbers contain ":" (e.g. 66:41:0304002:123) — illegal / awkward in +// filenames on some OSes. Replace with "_" for the download name only. +function sanitizeCad(cad: string): string { + return cad.replace(/:/g, "_"); +} + +function exportUrl(cad: string, format: "md" | "json" | "tg"): string { + return `${API_BASE_URL}/api/v1/parcels/${encodeURIComponent( + cad, + )}/forecast/export?format=${format}`; +} + +// Writes text to the clipboard with a graceful fallback for insecure contexts +// (HTTP / older browsers) where navigator.clipboard is undefined: an off-screen +// textarea + execCommand("copy"). Returns false if every path fails. +async function copyText(text: string): Promise { + if (typeof navigator !== "undefined" && navigator.clipboard) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // fall through to legacy path + } + } + if (typeof document === "undefined") return false; + try { + const ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); + ta.style.position = "absolute"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(ta); + return ok; + } catch { + return false; + } +} + +export function ForecastExportButtons({ cad }: Props) { + const [downloadingFormat, setDownloadingFormat] = + useState(null); + const [tgLoading, setTgLoading] = useState(false); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + + // Hold the «Скопировано» timer so it's cleared on unmount (no setState on a + // dead component) — this is timer cleanup, not an HTTP effect. + const copiedTimer = useRef | null>(null); + useEffect(() => { + return () => { + if (copiedTimer.current) clearTimeout(copiedTimer.current); + }; + }, []); + + async function handleDownload(format: ExportFormat) { + setDownloadingFormat(format); + setError(null); + try { + const res = await fetch(exportUrl(cad, format)); + if (!res.ok) { + setError(res.status === 404 ? NOT_READY_MSG : GENERIC_ERR_MSG); + return; + } + const blob = await res.blob(); + triggerDownload(blob, `gendesign_forecast_${sanitizeCad(cad)}.${format}`); + } catch { + setError(GENERIC_ERR_MSG); + } finally { + setDownloadingFormat(null); + } + } + + async function handleCopyTelegram() { + setTgLoading(true); + setError(null); + try { + const res = await fetch(exportUrl(cad, "tg")); + if (!res.ok) { + setError(res.status === 404 ? NOT_READY_MSG : GENERIC_ERR_MSG); + return; + } + const text = await res.text(); + const ok = await copyText(text); + if (!ok) { + setError("Не удалось скопировать — выделите текст вручную"); + return; + } + setCopied(true); + if (copiedTimer.current) clearTimeout(copiedTimer.current); + copiedTimer.current = setTimeout( + () => setCopied(false), + COPIED_FEEDBACK_MS, + ); + } catch { + setError(GENERIC_ERR_MSG); + } finally { + setTgLoading(false); + } + } + + const mdLoading = downloadingFormat === "md"; + const jsonLoading = downloadingFormat === "json"; + const anyLoading = downloadingFormat !== null || tgLoading; + + return ( +
+
+ void handleDownload("md")} + icon={} + /> + + void handleCopyTelegram()} + icon={ + copied ? ( + + ) : ( + + ) + } + /> + + void handleDownload("json")} + icon={} + /> +
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} + +// ── ExportButton (secondary / orange CTA) ──────────────────────────────────── + +function ExportButton({ + label, + loadingLabel, + loading, + disabled, + ariaLabel, + onClick, + icon, +}: { + label: string; + loadingLabel: string; + loading: boolean; + disabled: boolean; + ariaLabel: string; + onClick: () => void; + icon: React.ReactNode; +}) { + const inactive = loading || disabled; + return ( + + ); +} diff --git a/frontend/src/components/site-finder/analysis/Section6Forecast.tsx b/frontend/src/components/site-finder/analysis/Section6Forecast.tsx index 8c693fe1..29a583e7 100644 --- a/frontend/src/components/site-finder/analysis/Section6Forecast.tsx +++ b/frontend/src/components/site-finder/analysis/Section6Forecast.tsx @@ -27,6 +27,7 @@ import type { ForecastReport } from "@/types/forecast"; import { ForecastHorizonsBlock } from "./ForecastHorizonsBlock"; import { ScenariosBlock } from "./ScenariosBlock"; import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock"; +import { ForecastExportButtons } from "./ForecastExportButtons"; import { CONFIDENCE_RU, deficitVariant, fmtNum } from "./forecast-helpers"; interface Props { @@ -143,15 +144,23 @@ export function Section6Forecast({ cad, selectedHorizon }: Props) { ); } - return ; + return ( + + ); } // ── ForecastReady (report present) ─────────────────────────────────────────── function ForecastReady({ + cad, report, selectedHorizon, }: { + cad: string; report: ForecastReport; selectedHorizon?: number; }) { @@ -197,6 +206,9 @@ function ForecastReady({ gap: 24, }} > + {/* Export / share forecast report (EPIC #959) */} + + {/* exec_summary verdict + KPI row */} {exec.verdict && (