feat(site-finder): forecast export control in Section 6 (#959)
«Скачать отчёт .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:
parent
f8aa1d7512
commit
858b5f4511
4 changed files with 278 additions and 12 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
21
frontend/src/lib/download.ts
Normal file
21
frontend/src/lib/download.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue