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 { 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) {
|
||||
|
|
|
|||
|
|
@ -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 { 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 <ForecastReady report={data.report} selectedHorizon={selectedHorizon} />;
|
||||
return (
|
||||
<ForecastReady
|
||||
cad={cad}
|
||||
report={data.report}
|
||||
selectedHorizon={selectedHorizon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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) */}
|
||||
<ForecastExportButtons cad={cad} />
|
||||
|
||||
{/* exec_summary verdict + KPI row */}
|
||||
{exec.verdict && (
|
||||
<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