This commit is contained in:
parent
f41ca530c5
commit
49304a78bc
3 changed files with 257 additions and 39 deletions
|
|
@ -9,13 +9,14 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Download, FileText, FileBarChart } from "lucide-react";
|
import { Download, FileText, FileBarChart, FileType } 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";
|
||||||
import {
|
import {
|
||||||
useFullReport,
|
useFullReport,
|
||||||
formatElapsed,
|
formatElapsed,
|
||||||
readyReportLabel,
|
readyReportLabel,
|
||||||
|
readyDocxLabel,
|
||||||
} from "@/hooks/useFullReport";
|
} from "@/hooks/useFullReport";
|
||||||
import type { ParcelAnalyzeResponse, ParcelEgrn } from "@/lib/site-finder-api";
|
import type { ParcelAnalyzeResponse, ParcelEgrn } from "@/lib/site-finder-api";
|
||||||
|
|
||||||
|
|
@ -238,6 +239,46 @@ export function ExportButtons({ cad, analyzeData, egrn }: Props) {
|
||||||
? readyReportLabel(fullReport.reportGeneratedAt)
|
? readyReportLabel(fullReport.reportGeneratedAt)
|
||||||
: "Полный отчёт (PDF)"}
|
: "Полный отчёт (PDF)"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Full-report DOCX — вторичный компакт рядом с «Полный отчёт (PDF)».
|
||||||
|
Тот же общий прогон useFullReport (один enqueue→poll даёт pdf+docx),
|
||||||
|
финальный download в ?format=docx. Компактнее PDF-кнопки (иконка +
|
||||||
|
«DOCX»), чтобы не перегружать ряд. Building/ready-состояние — общее с
|
||||||
|
PDF-кнопкой (обе дизейблятся на сборке).
|
||||||
|
*/}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
reportReady ? fullReport.download("docx") : fullReport.start("docx")
|
||||||
|
}
|
||||||
|
disabled={reportBuilding}
|
||||||
|
aria-label={
|
||||||
|
reportReady
|
||||||
|
? "Скачать полный отчёт Word"
|
||||||
|
: "Собрать и скачать полный отчёт Word"
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "6px 12px",
|
||||||
|
height: 32,
|
||||||
|
background: reportBuilding
|
||||||
|
? "var(--bg-card-alt)"
|
||||||
|
: "var(--accent-2)",
|
||||||
|
color: reportBuilding ? "var(--fg-tertiary)" : "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: reportBuilding ? "not-allowed" : "pointer",
|
||||||
|
opacity: reportBuilding ? 0.6 : 1,
|
||||||
|
transition: "opacity 150ms",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileType size={14} strokeWidth={1.5} />
|
||||||
|
{reportReady ? readyDocxLabel(fullReport.reportGeneratedAt) : "DOCX"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Full-report timeout / enqueue-error note (contract has no "failed") */}
|
{/* Full-report timeout / enqueue-error note (contract has no "failed") */}
|
||||||
|
|
@ -257,6 +298,15 @@ export function ExportButtons({ cad, analyzeData, egrn }: Props) {
|
||||||
Не удалось поставить сборку отчёта — повторите
|
Не удалось поставить сборку отчёта — повторите
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{/* Легаси-ран без docx (собран до PR-F) → download?format=docx = 404. */}
|
||||||
|
{fullReport.downloadError && (
|
||||||
|
<p
|
||||||
|
style={{ margin: 0, fontSize: 12, color: "var(--danger)" }}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{fullReport.downloadError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
{error && (
|
{error && (
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,17 @@
|
||||||
* being ready (useParcelForecastQuery): until then the cards are disabled
|
* being ready (useParcelForecastQuery): until then the cards are disabled
|
||||||
* with an honest «готовится» tag.
|
* with an honest «готовится» tag.
|
||||||
*
|
*
|
||||||
* Full-report DOCX / PPTX (the prototype's editable-Word / presentation cards)
|
* Full-report DOCX (#2259 R2 PR-F) — REAL: GET /report/download?format=docx.
|
||||||
* have NO backend endpoint — the only Word/PowerPoint export the backend ships
|
* One report build produces BOTH pdf + docx, so the DOCX card shares the SAME
|
||||||
* is the §22 forecast above. So those two cards render DISABLED with a «скоро»
|
* useFullReport(cad) run as the full-report PDF card (enqueue → poll → download)
|
||||||
* tag (honest — we never trigger a download that 404s).
|
* — clicking either kicks the same job; only the final download format differs
|
||||||
|
* (remembered by the hook via pendingFormatRef). Legacy runs (built before PR-F)
|
||||||
|
* carry no docx_path → the hook HEAD-preflights the docx URL and, on 404, shows
|
||||||
|
* «Пересоберите отчёт» instead of silently downloading an HTML error page.
|
||||||
|
*
|
||||||
|
* Full-report PPTX (the prototype's presentation card) has NO backend endpoint —
|
||||||
|
* the only PowerPoint export the backend ships is the §22 forecast above. So that
|
||||||
|
* card renders DISABLED with a «скоро» tag (honest — we never trigger a 404).
|
||||||
*
|
*
|
||||||
* The «Состав отчёта» list is a visual checklist of the PDF snapshot's sections
|
* The «Состав отчёта» list is a visual checklist of the PDF snapshot's sections
|
||||||
* (matches the prototype TOC); the PDF snapshot is the real multi-section
|
* (matches the prototype TOC); the PDF snapshot is the real multi-section
|
||||||
|
|
@ -34,6 +41,7 @@ import {
|
||||||
useFullReport,
|
useFullReport,
|
||||||
formatElapsed,
|
formatElapsed,
|
||||||
readyReportLabel,
|
readyReportLabel,
|
||||||
|
readyDocxLabel,
|
||||||
} from "@/hooks/useFullReport";
|
} from "@/hooks/useFullReport";
|
||||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
import { StatusRow } from "@/components/site-finder/ptica/drawers/DrawerPrimitives";
|
import { StatusRow } from "@/components/site-finder/ptica/drawers/DrawerPrimitives";
|
||||||
|
|
@ -364,6 +372,55 @@ export function PticaReports({ cad, analysis }: Props) {
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Full-report DOCX — REAL (#2259 R2 PR-F): same run as PDF card,
|
||||||
|
final download ?format=docx. Building-спиннер общий на прогон. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.exportCard}
|
||||||
|
onClick={() =>
|
||||||
|
fullReport.uiState === "ready"
|
||||||
|
? fullReport.download("docx")
|
||||||
|
: fullReport.start("docx")
|
||||||
|
}
|
||||||
|
disabled={fullReport.uiState === "building"}
|
||||||
|
aria-label={
|
||||||
|
fullReport.uiState === "ready"
|
||||||
|
? "Скачать полный отчёт в формате Word"
|
||||||
|
: "Собрать и скачать полный отчёт в формате Word"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={`${styles.fmt} ${styles.fmtDocx}`}>DOCX</span>
|
||||||
|
<span className={styles.exportMeta}>
|
||||||
|
<span className={styles.exportTitle}>
|
||||||
|
{fullReport.uiState === "building"
|
||||||
|
? `Готовится… ${formatElapsed(fullReport.elapsedMs)}`
|
||||||
|
: fullReport.uiState === "ready"
|
||||||
|
? readyDocxLabel(fullReport.reportGeneratedAt)
|
||||||
|
: "Полный отчёт"}
|
||||||
|
</span>
|
||||||
|
<span className={styles.exportDesc}>
|
||||||
|
{fullReport.uiState === "timeout"
|
||||||
|
? "Сборка затянулась — попробуйте позже"
|
||||||
|
: fullReport.uiState === "error"
|
||||||
|
? "Не удалось поставить сборку — повторите"
|
||||||
|
: "Все секции · Word"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{fullReport.uiState === "building" ? (
|
||||||
|
<span
|
||||||
|
className={styles.pendingPulse}
|
||||||
|
style={{ width: 16, height: 16, borderWidth: 2 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FileType
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
className={styles.exportIco}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Full-report PPTX — NO endpoint → honest disabled «скоро» */}
|
{/* Full-report PPTX — NO endpoint → honest disabled «скоро» */}
|
||||||
<div
|
<div
|
||||||
className={`${styles.exportCard} ${styles.exportCardDisabled}`}
|
className={`${styles.exportCard} ${styles.exportCardDisabled}`}
|
||||||
|
|
@ -387,6 +444,14 @@ export function PticaReports({ cad, analysis }: Props) {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Легаси-раны без docx (собраны до PR-F) → download?format=docx = 404.
|
||||||
|
Хук ловит это HEAD-preflight'ом и просит пересобрать отчёт. */}
|
||||||
|
{fullReport.downloadError && (
|
||||||
|
<p className={styles.reportError} role="alert">
|
||||||
|
{fullReport.downloadError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Параметры — что войдёт в сгенерированный отчёт ─────────────── */}
|
{/* ── Параметры — что войдёт в сгенерированный отчёт ─────────────── */}
|
||||||
<hr className={styles.overlayDivider} />
|
<hr className={styles.overlayDivider} />
|
||||||
<h3 className={styles.cardTitle}>
|
<h3 className={styles.cardTitle}>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,20 @@ export type ReportStatusResponse =
|
||||||
* → 200 { status: "ready", ... } готовый кэш уже на диске, качать сразу
|
* → 200 { status: "ready", ... } готовый кэш уже на диске, качать сразу
|
||||||
* • GET /api/v1/parcels/{cad}/report/status
|
* • GET /api/v1/parcels/{cad}/report/status
|
||||||
* → { status: "none" | "building" | "ready", report_generated_at?, ... }
|
* → { status: "none" | "building" | "ready", report_generated_at?, ... }
|
||||||
* • GET /api/v1/parcels/{cad}/report/download → application/pdf (attachment)
|
* • GET /api/v1/parcels/{cad}/report/download?format=pdf|docx
|
||||||
|
* → application/pdf | application/vnd.openxmlformats-…docx (attachment)
|
||||||
|
*
|
||||||
|
* Один report-билд даёт ОБА файла (PDF + DOCX) — status=ready означает, что оба
|
||||||
|
* готовы для свежих ранов (#2259 R2 PR-F). Формат влияет только на финальный
|
||||||
|
* download: и PDF-, и DOCX-карточка гоняют ОДИН общий прогон enqueue→poll,
|
||||||
|
* а какой файл скачать в конце — запоминает pendingFormatRef (кто кликнул — тот
|
||||||
|
* формат и качается). Отдельного state под формат НЕТ.
|
||||||
|
*
|
||||||
|
* Легаси-раны (собранные до PR-F) не несут docx_path → download?format=docx даёт
|
||||||
|
* 404. Чтобы <a download> не «молча» скачивал HTML-страницу ошибки, DOCX-скачивание
|
||||||
|
* идёт через HEAD-preflight (triggerReportDownload с preflight): 404 → onDownloadError
|
||||||
|
* («Пересоберите отчёт»), ok → навигация <a>. PDF-путь preflight не нужен (файл всегда
|
||||||
|
* есть при ready), остаётся прямой <a>-навигацией.
|
||||||
*
|
*
|
||||||
* «failed»-состояния в контракте НЕТ: если building висит дольше POLL_CAP_MS,
|
* «failed»-состояния в контракте НЕТ: если building висит дольше POLL_CAP_MS,
|
||||||
* фронт сам останавливает поллинг и показывает «Сборка затянулась — попробуйте
|
* фронт сам останавливает поллинг и показывает «Сборка затянулась — попробуйте
|
||||||
|
|
@ -40,6 +53,9 @@ export type ReportStatusResponse =
|
||||||
const POLL_INTERVAL_MS = 5_000;
|
const POLL_INTERVAL_MS = 5_000;
|
||||||
const POLL_CAP_MS = 3 * 60 * 1000;
|
const POLL_CAP_MS = 3 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Формат финального download'а. Один прогон enqueue→poll — формат только у скачивания. */
|
||||||
|
export type ReportFormat = "pdf" | "docx";
|
||||||
|
|
||||||
export type FullReportUiState =
|
export type FullReportUiState =
|
||||||
| "idle" // ничего не запускалось / ещё не готов
|
| "idle" // ничего не запускалось / ещё не готов
|
||||||
| "building" // джоба идёт, поллим status
|
| "building" // джоба идёт, поллим status
|
||||||
|
|
@ -53,25 +69,40 @@ export interface UseFullReportResult {
|
||||||
reportGeneratedAt: string | null;
|
reportGeneratedAt: string | null;
|
||||||
/** Прошедшее с начала сборки время, мс (для «Готовится… мм:сс»). */
|
/** Прошедшее с начала сборки время, мс (для «Готовится… мм:сс»). */
|
||||||
elapsedMs: number;
|
elapsedMs: number;
|
||||||
/** Клик по кнопке: enqueue → сразу download если ready, иначе старт поллинга. */
|
/**
|
||||||
start: () => void;
|
* Клик по кнопке: enqueue → сразу download в `format` если ready, иначе старт
|
||||||
/** Ручное скачивание готового PDF (кнопка «Скачать отчёт»). */
|
* поллинга (формат запоминается — финальный авто-download будет в нём же).
|
||||||
download: () => void;
|
* format по умолчанию "pdf" — обратная совместимость со старыми вызовами.
|
||||||
|
*/
|
||||||
|
start: (format?: ReportFormat) => void;
|
||||||
|
/** Ручное скачивание готового отчёта в `format` (кнопка «Скачать …»). */
|
||||||
|
download: (format?: ReportFormat) => void;
|
||||||
|
/**
|
||||||
|
* Легаси-раны без docx: download?format=docx вернул 404 → сообщение
|
||||||
|
* «Пересоберите отчёт». null пока ошибки нет. Сбрасывается на новом start().
|
||||||
|
*/
|
||||||
|
downloadError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildReportPath(cad: string, suffix: string): string {
|
function buildReportPath(cad: string, suffix: string): string {
|
||||||
return `/api/v1/parcels/${encodeURIComponent(cad)}/report${suffix}`;
|
return `/api/v1/parcels/${encodeURIComponent(cad)}/report${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** URL download'а с ?format= (pdf — дефолт бэкенда, не гоним лишний query). */
|
||||||
|
function downloadUrl(cad: string, format: ReportFormat): string {
|
||||||
|
const base = `${API_BASE_URL}${buildReportPath(cad, "/download")}`;
|
||||||
|
return format === "docx" ? `${base}?format=docx` : base;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Навигация браузера к /report/download через скрытый <a download>. Наследует
|
* Навигация браузера к /report/download через скрытый <a download>. Наследует
|
||||||
* Caddy basic_auth сессию текущей вкладки — отдельный авторизованный fetch не
|
* Caddy basic_auth сессию текущей вкладки — отдельный авторизованный fetch не
|
||||||
* нужен. Browser-only (использует document) — вызывать только из обработчика.
|
* нужен. Browser-only (использует document) — вызывать только из обработчика.
|
||||||
*/
|
*/
|
||||||
function triggerReportDownload(cad: string): void {
|
function triggerAnchorDownload(cad: string, format: ReportFormat): void {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = `${API_BASE_URL}${buildReportPath(cad, "/download")}`;
|
a.href = downloadUrl(cad, format);
|
||||||
// Content-Disposition: attachment на бэкенде задаёт имя файла; download-атрибут
|
// Content-Disposition: attachment на бэкенде задаёт имя файла; download-атрибут
|
||||||
// без значения лишь подтверждает намерение скачать, не переопределяя имя.
|
// без значения лишь подтверждает намерение скачать, не переопределяя имя.
|
||||||
a.download = "";
|
a.download = "";
|
||||||
|
|
@ -81,16 +112,59 @@ function triggerReportDownload(cad: string): void {
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скачать отчёт в `format`. PDF-путь — прямая <a>-навигация (файл всегда есть при
|
||||||
|
* ready). DOCX-путь — HEAD-preflight: легаси-раны без docx_path отдают 404, а
|
||||||
|
* <a download> на 404 «молча» скачал бы HTML-страницу ошибки; ловим здесь и зовём
|
||||||
|
* onError («Пересоберите отчёт»). HEAD дешевле GET (тело не тянется) и наследует
|
||||||
|
* ту же basic_auth сессию, что и <a>. Сеть упала — не блокируем: пробуем <a>.
|
||||||
|
*/
|
||||||
|
async function triggerReportDownload(
|
||||||
|
cad: string,
|
||||||
|
format: ReportFormat,
|
||||||
|
onError: () => void,
|
||||||
|
): Promise<void> {
|
||||||
|
if (format === "pdf") {
|
||||||
|
triggerAnchorDownload(cad, "pdf");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(downloadUrl(cad, "docx"), { method: "HEAD" });
|
||||||
|
// Любой не-OK (404 легаси-рана, 5xx) — не даём <a> молча скачать страницу ошибки.
|
||||||
|
if (!res.ok) {
|
||||||
|
onError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Сетевая ошибка preflight'а (offline / CORS) — не проглатываем download:
|
||||||
|
// отдаём <a>-навигации, пусть браузер сам разрулит.
|
||||||
|
}
|
||||||
|
triggerAnchorDownload(cad, "docx");
|
||||||
|
}
|
||||||
|
|
||||||
export function useFullReport(cad: string): UseFullReportResult {
|
export function useFullReport(cad: string): UseFullReportResult {
|
||||||
// Флаг активного поллинга — заводится на building, гасится на ready/timeout.
|
// Флаг активного поллинга — заводится на building, гасится на ready/timeout.
|
||||||
const [polling, setPolling] = useState(false);
|
const [polling, setPolling] = useState(false);
|
||||||
const [timedOut, setTimedOut] = useState(false);
|
const [timedOut, setTimedOut] = useState(false);
|
||||||
const [elapsedMs, setElapsedMs] = useState(0);
|
const [elapsedMs, setElapsedMs] = useState(0);
|
||||||
|
// Легаси-404 при download?format=docx — сообщение «Пересоберите отчёт».
|
||||||
|
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||||
// Момент начала сборки — источник истины для elapsed и cap.
|
// Момент начала сборки — источник истины для elapsed и cap.
|
||||||
const startedAtRef = useRef<number | null>(null);
|
const startedAtRef = useRef<number | null>(null);
|
||||||
// На ready один раз авто-скачиваем — иначе повторные тики status дёргали бы
|
// На ready один раз авто-скачиваем — иначе повторные тики status дёргали бы
|
||||||
// загрузку снова.
|
// загрузку снова.
|
||||||
const autoDownloadedRef = useRef(false);
|
const autoDownloadedRef = useRef(false);
|
||||||
|
// Формат финального авто-download'а — «кто кликнул, тот формат и качается».
|
||||||
|
// Ref (не state): читается только внутри обработчиков/эффекта, ре-рендер под
|
||||||
|
// него не нужен, и это держит один общий прогон без дубля состояния формата.
|
||||||
|
const pendingFormatRef = useRef<ReportFormat>("pdf");
|
||||||
|
|
||||||
|
// Стабильный колбэк ошибки docx-download'а для triggerReportDownload.
|
||||||
|
const handleDownloadError = useCallback(() => {
|
||||||
|
setDownloadError(
|
||||||
|
"Пересоберите отчёт — DOCX для старого прогона отсутствует",
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ── POST enqueue ────────────────────────────────────────────────────────────
|
// ── POST enqueue ────────────────────────────────────────────────────────────
|
||||||
const enqueue = useMutation({
|
const enqueue = useMutation({
|
||||||
|
|
@ -142,16 +216,20 @@ export function useFullReport(cad: string): UseFullReportResult {
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [polling]);
|
}, [polling]);
|
||||||
|
|
||||||
// ── На ready: авто-download один раз + стоп поллинга ──────────────────────────
|
// ── На ready: авто-download один раз (в запомненном формате) + стоп поллинга ───
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (statusData?.status === "ready" && polling) {
|
if (statusData?.status === "ready" && polling) {
|
||||||
setPolling(false);
|
setPolling(false);
|
||||||
if (!autoDownloadedRef.current) {
|
if (!autoDownloadedRef.current) {
|
||||||
autoDownloadedRef.current = true;
|
autoDownloadedRef.current = true;
|
||||||
triggerReportDownload(cad);
|
void triggerReportDownload(
|
||||||
|
cad,
|
||||||
|
pendingFormatRef.current,
|
||||||
|
handleDownloadError,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [statusData?.status, polling, cad]);
|
}, [statusData?.status, polling, cad, handleDownloadError]);
|
||||||
|
|
||||||
// ── Смена участка: полный сброс прогона ──────────────────────────────────────
|
// ── Смена участка: полный сброс прогона ──────────────────────────────────────
|
||||||
// Оба потребителя живут под route-сегментом [cad] (смена участка = remount),
|
// Оба потребителя живут под route-сегментом [cad] (смена участка = remount),
|
||||||
|
|
@ -160,15 +238,21 @@ export function useFullReport(cad: string): UseFullReportResult {
|
||||||
setPolling(false);
|
setPolling(false);
|
||||||
setTimedOut(false);
|
setTimedOut(false);
|
||||||
setElapsedMs(0);
|
setElapsedMs(0);
|
||||||
|
setDownloadError(null);
|
||||||
autoDownloadedRef.current = false;
|
autoDownloadedRef.current = false;
|
||||||
startedAtRef.current = null;
|
startedAtRef.current = null;
|
||||||
|
pendingFormatRef.current = "pdf";
|
||||||
}, [cad]);
|
}, [cad]);
|
||||||
|
|
||||||
// ── Действие: клик по кнопке ──────────────────────────────────────────────────
|
// ── Действие: клик по кнопке ──────────────────────────────────────────────────
|
||||||
const start = useCallback(() => {
|
const start = useCallback(
|
||||||
// Сброс прошлого прогона (retry после timeout/error).
|
(format: ReportFormat = "pdf") => {
|
||||||
|
// Сброс прошлого прогона (retry после timeout/error) + запомнить формат
|
||||||
|
// финального download'а: кто кликнул PDF/DOCX — тот файл и скачается.
|
||||||
|
pendingFormatRef.current = format;
|
||||||
setTimedOut(false);
|
setTimedOut(false);
|
||||||
setElapsedMs(0);
|
setElapsedMs(0);
|
||||||
|
setDownloadError(null);
|
||||||
startedAtRef.current = null;
|
startedAtRef.current = null;
|
||||||
autoDownloadedRef.current = false;
|
autoDownloadedRef.current = false;
|
||||||
|
|
||||||
|
|
@ -177,7 +261,7 @@ export function useFullReport(cad: string): UseFullReportResult {
|
||||||
if (data.status === "ready") {
|
if (data.status === "ready") {
|
||||||
// Кэш готов — качаем сразу, поллинг не нужен.
|
// Кэш готов — качаем сразу, поллинг не нужен.
|
||||||
autoDownloadedRef.current = true;
|
autoDownloadedRef.current = true;
|
||||||
triggerReportDownload(cad);
|
void triggerReportDownload(cad, format, handleDownloadError);
|
||||||
} else {
|
} else {
|
||||||
// building → стартуем поллинг status.
|
// building → стартуем поллинг status.
|
||||||
startedAtRef.current = Date.now();
|
startedAtRef.current = Date.now();
|
||||||
|
|
@ -185,11 +269,17 @@ export function useFullReport(cad: string): UseFullReportResult {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [enqueue, cad]);
|
},
|
||||||
|
[enqueue, cad, handleDownloadError],
|
||||||
|
);
|
||||||
|
|
||||||
const download = useCallback(() => {
|
const download = useCallback(
|
||||||
triggerReportDownload(cad);
|
(format: ReportFormat = "pdf") => {
|
||||||
}, [cad]);
|
setDownloadError(null);
|
||||||
|
void triggerReportDownload(cad, format, handleDownloadError);
|
||||||
|
},
|
||||||
|
[cad, handleDownloadError],
|
||||||
|
);
|
||||||
|
|
||||||
// ── Производное UI-состояние ──────────────────────────────────────────────────
|
// ── Производное UI-состояние ──────────────────────────────────────────────────
|
||||||
let uiState: FullReportUiState = "idle";
|
let uiState: FullReportUiState = "idle";
|
||||||
|
|
@ -206,7 +296,14 @@ export function useFullReport(cad: string): UseFullReportResult {
|
||||||
const reportGeneratedAt =
|
const reportGeneratedAt =
|
||||||
(uiState === "ready" ? statusData?.report_generated_at : null) ?? null;
|
(uiState === "ready" ? statusData?.report_generated_at : null) ?? null;
|
||||||
|
|
||||||
return { uiState, reportGeneratedAt, elapsedMs, start, download };
|
return {
|
||||||
|
uiState,
|
||||||
|
reportGeneratedAt,
|
||||||
|
elapsedMs,
|
||||||
|
start,
|
||||||
|
download,
|
||||||
|
downloadError,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Хелперы микрокопии кнопки (общие для обоих компонентов) ────────────────────
|
// ── Хелперы микрокопии кнопки (общие для обоих компонентов) ────────────────────
|
||||||
|
|
@ -225,6 +322,12 @@ export function readyReportLabel(iso: string | null): string {
|
||||||
return d ? `Скачать отчёт (${d})` : "Скачать отчёт";
|
return d ? `Скачать отчёт (${d})` : "Скачать отчёт";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** «Скачать DOCX (дата)» — параллель readyReportLabel для DOCX-карточки. */
|
||||||
|
export function readyDocxLabel(iso: string | null): string {
|
||||||
|
const d = formatReportDate(iso);
|
||||||
|
return d ? `Скачать DOCX (${d})` : "Скачать DOCX";
|
||||||
|
}
|
||||||
|
|
||||||
/** ISO → «ru-RU» дата генерации отчёта (репо-конвенция, NaN-fallback). */
|
/** ISO → «ru-RU» дата генерации отчёта (репо-конвенция, NaN-fallback). */
|
||||||
export function formatReportDate(iso: string | null): string {
|
export function formatReportDate(iso: string | null): string {
|
||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue