feat(forecast): expose DOCX & PPTX export buttons in forecast UI
All checks were successful
CI / changes (pull_request) Successful in 5s
CI / backend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
CI / changes (push) Successful in 6s
CI / backend-tests (push) Has been skipped
Deploy / build-frontend (push) Successful in 28s
Deploy / deploy (push) Successful in 1m0s

Add «Скачать .docx» (Word) and «Скачать .pptx» (PowerPoint) buttons to
ForecastExportButtons, alongside the existing .md/.json/Telegram controls.
The backend /forecast/export already renders both formats; they were just
not reachable from the UI. Both reuse the existing response.blob() →
triggerDownload path (correct for binary zip-based OOXML — never .text(),
which would corrupt the archive). Per-format loading state; 404 →
«Отчёт ещё не готов». No new deps (icons from lucide-react).

Part of #958.
This commit is contained in:
Light1YT 2026-06-07 15:12:52 +05:00
parent a66c35ae49
commit 7131bed1f4

View file

@ -11,12 +11,17 @@
* Backend (live on prod): * Backend (live on prod):
* GET /api/v1/parcels/{cad}/forecast/export?format=md text/markdown attachment * 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=json application/json attachment
* GET /api/v1/parcels/{cad}/forecast/export?format=docx Word (.docx) attachment
* GET /api/v1/parcels/{cad}/forecast/export?format=pptx PowerPoint (.pptx) attachment
* GET /api/v1/parcels/{cad}/forecast/export?format=tg text/plain inline (TG snippet) * GET /api/v1/parcels/{cad}/forecast/export?format=tg text/plain inline (TG snippet)
* No forecast run 404 inline «Отчёт ещё не готов» (no crash, no console spam). * No forecast run 404 inline «Отчёт ещё не готов» (no crash, no console spam).
*
* docx/pptx are binary zip-based OOXML they go through the SAME response.blob()
* path as md/json (never response.text(), which would corrupt the archive).
*/ */
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Download, Copy, Check } from "lucide-react"; import { Download, Copy, Check, FileText, Presentation } from "lucide-react";
import { API_BASE_URL } from "@/lib/api"; import { API_BASE_URL } from "@/lib/api";
import { triggerDownload } from "@/lib/download"; import { triggerDownload } from "@/lib/download";
@ -25,7 +30,9 @@ interface Props {
cad: string; cad: string;
} }
type ExportFormat = "md" | "json"; // Binary OOXML formats (docx/pptx) share md/json's blob download path; only the
// query param + filename extension differ, so one discriminant covers them all.
type ExportFormat = "md" | "json" | "docx" | "pptx";
const NOT_READY_MSG = "Отчёт ещё не готов"; const NOT_READY_MSG = "Отчёт ещё не готов";
const GENERIC_ERR_MSG = "Не удалось получить отчёт"; const GENERIC_ERR_MSG = "Не удалось получить отчёт";
@ -37,7 +44,7 @@ function sanitizeCad(cad: string): string {
return cad.replace(/:/g, "_"); return cad.replace(/:/g, "_");
} }
function exportUrl(cad: string, format: "md" | "json" | "tg"): string { function exportUrl(cad: string, format: ExportFormat | "tg"): string {
return `${API_BASE_URL}/api/v1/parcels/${encodeURIComponent( return `${API_BASE_URL}/api/v1/parcels/${encodeURIComponent(
cad, cad,
)}/forecast/export?format=${format}`; )}/forecast/export?format=${format}`;
@ -136,6 +143,8 @@ export function ForecastExportButtons({ cad }: Props) {
const mdLoading = downloadingFormat === "md"; const mdLoading = downloadingFormat === "md";
const jsonLoading = downloadingFormat === "json"; const jsonLoading = downloadingFormat === "json";
const docxLoading = downloadingFormat === "docx";
const pptxLoading = downloadingFormat === "pptx";
const anyLoading = downloadingFormat !== null || tgLoading; const anyLoading = downloadingFormat !== null || tgLoading;
return ( return (
@ -176,6 +185,26 @@ export function ForecastExportButtons({ cad }: Props) {
onClick={() => void handleDownload("json")} onClick={() => void handleDownload("json")}
icon={<Download size={14} strokeWidth={1.5} />} icon={<Download size={14} strokeWidth={1.5} />}
/> />
<ExportButton
label="Скачать .docx"
loadingLabel="Загрузка…"
loading={docxLoading}
disabled={anyLoading}
ariaLabel="Скачать отчёт прогноза в формате Word (.docx)"
onClick={() => void handleDownload("docx")}
icon={<FileText size={14} strokeWidth={1.5} />}
/>
<ExportButton
label="Скачать .pptx"
loadingLabel="Загрузка…"
loading={pptxLoading}
disabled={anyLoading}
ariaLabel="Скачать отчёт прогноза в формате PowerPoint (.pptx)"
onClick={() => void handleDownload("pptx")}
icon={<Presentation size={14} strokeWidth={1.5} />}
/>
</div> </div>
{error && ( {error && (