feat(site-finder): forecast export control in Section 6 (#959)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m59s
Deploy / deploy (push) Successful in 1m2s

«Скачать отчёт .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.
This commit is contained in:
Light1YT 2026-06-07 13:40:34 +05:00 committed by bot-backend
parent f8aa1d7512
commit 858b5f4511
4 changed files with 278 additions and 12 deletions

View file

@ -11,6 +11,7 @@
import { useState } from "react"; import { useState } from "react";
import { Download, FileText } from "lucide-react"; import { Download, FileText } from "lucide-react";
import { API_BASE_URL } from "@/lib/api"; import { API_BASE_URL } from "@/lib/api";
import { triggerDownload } from "@/lib/download";
import type { ParcelAnalyzeResponse, ParcelEgrn } from "@/lib/site-finder-api"; import type { ParcelAnalyzeResponse, ParcelEgrn } from "@/lib/site-finder-api";
interface Props { interface Props {
@ -76,17 +77,6 @@ function generateCsvBlob(rows: string[][]): Blob {
return new Blob(["" + csvContent], { type: "text/csv;charset=utf-8" }); 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 ───────────────────────────────────────────────────────────────── // ── Component ─────────────────────────────────────────────────────────────────
export function ExportButtons({ cad, analyzeData }: Props) { export function ExportButtons({ cad, analyzeData }: Props) {

View file

@ -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<boolean> {
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<ExportFormat | null>(null);
const [tgLoading, setTgLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(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<ReturnType<typeof setTimeout> | 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 (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<ExportButton
label="Скачать отчёт .md"
loadingLabel="Загрузка…"
loading={mdLoading}
disabled={anyLoading}
ariaLabel="Скачать отчёт прогноза в формате Markdown"
onClick={() => void handleDownload("md")}
icon={<Download size={14} strokeWidth={1.5} />}
/>
<ExportButton
label={copied ? "Скопировано" : "Скопировать для Telegram"}
loadingLabel="Загрузка…"
loading={tgLoading}
disabled={anyLoading}
ariaLabel="Скопировать сводку прогноза для Telegram"
onClick={() => void handleCopyTelegram()}
icon={
copied ? (
<Check size={14} strokeWidth={1.5} />
) : (
<Copy size={14} strokeWidth={1.5} />
)
}
/>
<ExportButton
label="Скачать .json"
loadingLabel="Загрузка…"
loading={jsonLoading}
disabled={anyLoading}
ariaLabel="Скачать отчёт прогноза в формате JSON"
onClick={() => void handleDownload("json")}
icon={<Download size={14} strokeWidth={1.5} />}
/>
</div>
{error && (
<p
style={{
margin: 0,
fontSize: 12,
color: "var(--danger)",
}}
role="alert"
>
{error}
</p>
)}
</div>
);
}
// ── 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 (
<button
onClick={onClick}
disabled={inactive}
aria-label={ariaLabel}
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "6px 12px",
height: 32,
background: inactive ? "var(--bg-card-alt)" : "var(--accent-2)",
color: inactive ? "var(--fg-tertiary)" : "#fff",
border: "none",
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
cursor: inactive ? "not-allowed" : "pointer",
opacity: inactive ? 0.6 : 1,
transition: "opacity 150ms",
}}
>
{icon}
{loading ? loadingLabel : label}
</button>
);
}

View file

@ -27,6 +27,7 @@ import type { ForecastReport } from "@/types/forecast";
import { ForecastHorizonsBlock } from "./ForecastHorizonsBlock"; import { ForecastHorizonsBlock } from "./ForecastHorizonsBlock";
import { ScenariosBlock } from "./ScenariosBlock"; import { ScenariosBlock } from "./ScenariosBlock";
import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock"; import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
import { ForecastExportButtons } from "./ForecastExportButtons";
import { CONFIDENCE_RU, deficitVariant, fmtNum } from "./forecast-helpers"; import { CONFIDENCE_RU, deficitVariant, fmtNum } from "./forecast-helpers";
interface Props { interface Props {
@ -143,15 +144,23 @@ export function Section6Forecast({ cad, selectedHorizon }: Props) {
); );
} }
return <ForecastReady report={data.report} selectedHorizon={selectedHorizon} />; return (
<ForecastReady
cad={cad}
report={data.report}
selectedHorizon={selectedHorizon}
/>
);
} }
// ── ForecastReady (report present) ─────────────────────────────────────────── // ── ForecastReady (report present) ───────────────────────────────────────────
function ForecastReady({ function ForecastReady({
cad,
report, report,
selectedHorizon, selectedHorizon,
}: { }: {
cad: string;
report: ForecastReport; report: ForecastReport;
selectedHorizon?: number; selectedHorizon?: number;
}) { }) {
@ -197,6 +206,9 @@ function ForecastReady({
gap: 24, gap: 24,
}} }}
> >
{/* Export / share forecast report (EPIC #959) */}
<ForecastExportButtons cad={cad} />
{/* exec_summary verdict + KPI row */} {/* exec_summary verdict + KPI row */}
{exec.verdict && ( {exec.verdict && (
<p <p

View file

@ -0,0 +1,21 @@
/**
* triggerDownload shared blob file-download helper.
*
* Creates a transient object URL for `blob`, clicks a hidden anchor to start
* the browser download as `filename`, then revokes the URL. Used by both the
* analysis ExportButtons (PDF/CSV) and the Section 6 forecast export control
* (.md / .json) keep it dependency-free so it stays trivially reusable.
*
* Browser-only: relies on `document` / `URL.createObjectURL`. Call from a
* click handler, never during SSR.
*/
export 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);
}